UNPKG

@ubiquity-os/plugin-sdk

Version:

SDK for plugin support.

298 lines (297 loc) 10.1 kB
// src/llm/index.ts var EMPTY_STRING = ""; function normalizeBaseUrl(baseUrl) { let normalized = baseUrl.trim(); while (normalized.endsWith("/")) { normalized = normalized.slice(0, -1); } return normalized; } var MAX_LLM_RETRIES = 2; var RETRY_BACKOFF_MS = [250, 750]; function getRetryDelayMs(attempt) { return RETRY_BACKOFF_MS[Math.min(attempt, RETRY_BACKOFF_MS.length - 1)] ?? 750; } function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } function getEnvString(name) { if (typeof process === "undefined" || !process?.env) return EMPTY_STRING; return String(process.env[name] ?? EMPTY_STRING).trim(); } function normalizeToken(value) { return typeof value === "string" ? value.trim() : EMPTY_STRING; } function isGitHubToken(token) { return token.trim().startsWith("gh"); } function readKernelRefreshUrl(value) { if (!value || typeof value !== "object") return EMPTY_STRING; return normalizeToken(value.kernelRefreshUrl); } function getKernelRefreshUrl(input) { const direct = readKernelRefreshUrl(input); if (direct) return direct; const fromConfig = readKernelRefreshUrl(input.config); if (fromConfig) return fromConfig; return readKernelRefreshUrl(input.settings); } function updateInputTokens(input, tokens) { if ("authToken" in input) { input.authToken = tokens.authToken; } if ("ubiquityKernelToken" in input) { input.ubiquityKernelToken = tokens.kernelToken; } } function getEnvTokenFromInput(input) { if ("env" in input) { const envValue = input.env; if (envValue && typeof envValue === "object") { const token = normalizeToken(envValue.UOS_AI_TOKEN); if (token) return token; } } return getEnvString("UOS_AI_TOKEN"); } function resolveAuthToken(input, aiAuthToken) { const explicit = normalizeToken(aiAuthToken); if (explicit) return { token: explicit, isGitHub: isGitHubToken(explicit) }; const envToken = getEnvTokenFromInput(input); if (envToken) return { token: envToken, isGitHub: isGitHubToken(envToken) }; const fallback = normalizeToken(input.authToken); if (!fallback) { const err = new Error("Missing auth token; set UOS_AI_TOKEN, pass aiAuthToken, or provide authToken in input"); err.status = 401; throw err; } return { token: fallback, isGitHub: isGitHubToken(fallback) }; } function getAiBaseUrl(options) { if (typeof options.baseUrl === "string" && options.baseUrl.trim()) { return normalizeBaseUrl(options.baseUrl); } const envBaseUrl = getEnvString("UOS_AI_URL"); if (envBaseUrl) return normalizeBaseUrl(envBaseUrl); return "https://ai-ubq-fi.deno.dev"; } async function callLlm(options, input) { const { baseUrl, model, stream: isStream, messages, aiAuthToken, ...rest } = options; ensureMessages(messages); const { token: resolvedAuthToken, isGitHub } = resolveAuthToken(input, aiAuthToken); let authToken = resolvedAuthToken; let kernelToken = "ubiquityKernelToken" in input ? input.ubiquityKernelToken : void 0; const payload = getPayload(input); const { owner, repo, installationId } = getRepoMetadata(payload); const kernelRefreshUrl = getKernelRefreshUrl(input); const hasExplicitAiAuth = Boolean(normalizeToken(aiAuthToken)); if (isGitHub && kernelRefreshUrl && kernelToken && !hasExplicitAiAuth) { const refreshed = await refreshKernelTokens({ url: kernelRefreshUrl, authToken, kernelToken, owner, repo, installationId }); authToken = refreshed.authToken; kernelToken = refreshed.kernelToken; updateInputTokens(input, refreshed); } const url = buildAiUrl(options, baseUrl); const body = JSON.stringify({ ...rest, ...model ? { model } : {}, messages, stream: isStream ?? false }); const headers = buildHeaders(authToken, { owner, repo, installationId, ubiquityKernelToken: isGitHub ? kernelToken : void 0 }); const response = await fetchWithRetry(url, { method: "POST", headers, body }, MAX_LLM_RETRIES); if (isStream) { if (!response.body) { throw new Error("LLM API error: missing response body for streaming request"); } return parseSseStream(response.body); } const rawText = await response.text(); try { return JSON.parse(rawText); } catch (err) { const preview = rawText ? rawText.slice(0, 1e3) : EMPTY_STRING; const message = "LLM API error: failed to parse JSON response from server" + (preview ? `; response body (truncated): ${preview}` : EMPTY_STRING); const error = new Error(message); error.cause = err; error.status = response.status; throw error; } } function ensureMessages(messages) { if (!Array.isArray(messages) || messages.length === 0) { const err = new Error("messages must be a non-empty array"); err.status = 400; throw err; } } function buildAiUrl(options, baseUrl) { return `${getAiBaseUrl({ ...options, baseUrl })}/v1/chat/completions`; } async function refreshKernelTokens(params) { const headers = buildHeaders(params.authToken, { owner: params.owner, repo: params.repo, installationId: params.installationId, ubiquityKernelToken: params.kernelToken }); const response = await fetch(params.url, { method: "POST", headers }); const text = await response.text().catch(() => EMPTY_STRING); if (!response.ok) { const error = new Error(`Kernel refresh error: ${response.status} - ${text}`); error.status = response.status; throw error; } let payload = {}; if (text.trim()) { try { payload = JSON.parse(text); } catch (err) { const details = err instanceof Error ? ` (${err.message})` : ""; const error = new Error(`Kernel refresh error: failed to parse JSON response${details}`); error.status = response.status; throw error; } } const authToken = normalizeToken(payload.authToken); const kernelToken = normalizeToken(payload.ubiquityKernelToken); if (!authToken || !kernelToken) { const error = new Error("Kernel refresh error: response missing authToken or ubiquityKernelToken"); error.status = 502; throw error; } return { authToken, kernelToken, expiresAt: typeof payload.expiresAt === "string" ? payload.expiresAt : null }; } async function fetchWithRetry(url, options, maxRetries) { let attempt = 0; let lastError; while (attempt <= maxRetries) { try { const response = await fetch(url, options); if (response.ok) return response; throw await buildResponseError(response); } catch (error) { lastError = error; if (!shouldRetryError(error, attempt, maxRetries)) throw error; await sleep(getRetryDelayMs(attempt)); attempt += 1; } } throw lastError ?? new Error("LLM API error: request failed after retries"); } async function buildResponseError(response) { const errText = await response.text(); const error = new Error(`LLM API error: ${response.status} - ${errText}`); error.status = response.status; return error; } function shouldRetryError(error, attempt, maxRetries) { if (attempt >= maxRetries) return false; const status = getErrorStatus(error); if (typeof status === "number") { return status >= 500; } return true; } function getErrorStatus(error) { return typeof error?.status === "number" ? error.status : void 0; } function getPayload(input) { if ("payload" in input) { return input.payload; } return input.eventPayload; } function getRepoMetadata(payload) { const repoPayload = payload; return { owner: repoPayload?.repository?.owner?.login ?? EMPTY_STRING, repo: repoPayload?.repository?.name ?? EMPTY_STRING, installationId: repoPayload?.installation?.id }; } function buildHeaders(authToken, options) { const headers = { Authorization: `Bearer ${authToken}`, "Content-Type": "application/json" }; if (options.owner) headers["X-GitHub-Owner"] = options.owner; if (options.repo) headers["X-GitHub-Repo"] = options.repo; if (typeof options.installationId === "number" && Number.isFinite(options.installationId)) { headers["X-GitHub-Installation-Id"] = String(options.installationId); } if (options.ubiquityKernelToken) { headers["X-Ubiquity-Kernel-Token"] = options.ubiquityKernelToken; } return headers; } async function* parseSseStream(body) { const reader = body.getReader(); const decoder = new TextDecoder(); let buffer = EMPTY_STRING; try { while (true) { const { value, done: isDone } = await reader.read(); if (isDone) break; buffer += decoder.decode(value, { stream: true }); const { events, remainder } = splitSseEvents(buffer); buffer = remainder; for (const event of events) { const data = getEventData(event); if (!data) continue; if (data.trim() === "[DONE]") return; yield parseEventData(data); } } } finally { reader.releaseLock(); } } function splitSseEvents(buffer) { const normalized = buffer.replace(/\r\n/g, "\n").replace(/\r/g, "\n"); const parts = normalized.split("\n\n"); const remainder = parts.pop() ?? EMPTY_STRING; return { events: parts, remainder }; } function getEventData(event) { if (!event.trim()) return null; const dataLines = event.split("\n").filter((line) => line.startsWith("data:")); if (!dataLines.length) return null; const data = dataLines.map((line) => line.startsWith("data: ") ? line.slice(6) : line.slice(5).replace(/^ /, EMPTY_STRING)).join("\n"); return data || null; } function parseEventData(data) { try { return JSON.parse(data); } catch (error) { if (data.includes("\n")) { const collapsed = data.replace(/\n/g, EMPTY_STRING); try { return JSON.parse(collapsed); } catch { } } const message = error instanceof Error ? error.message : String(error); const preview = data.length > 200 ? `${data.slice(0, 200)}...` : data; throw new Error(`LLM stream parse error: ${message}. Data: ${preview}`); } } export { callLlm };