UNPKG

openclaw

Version:

Multi-channel AI gateway with extensible messaging integrations

314 lines (313 loc) 14.3 kB
import { r as truncateUtf16Safe } from "./utf16-slice-D_ngcYKd.js"; import { n as readResponseTextPrefix } from "./http-response-body-CwT_cCNz.js"; import "./response-limit-runtime-BV0RL9tn.js"; import "./realtime-voice-provider-CoGArOTN.js"; import { n as buildOpenAIQuicksilverBackgroundContext, t as OPENAI_QUICKSILVER_HOST_CONTROL_INSTRUCTIONS } from "./realtime-quicksilver-instructions-kTl3roXd.js"; import { a as isOpenAIGptLiveModel, s as resolveOpenAIQuicksilverVoice } from "./realtime-quicksilver-CmbnkSLp.js"; import "./realtime-quicksilver-events-CbzMdwSI.js"; import { randomBytes } from "node:crypto"; //#region extensions/openai/realtime-quicksilver-wire.ts const OPENAI_QUICKSILVER_APPEND_MAX_BYTES = 500; const OPENAI_QUICKSILVER_DELEGATION_RESULT_MAX_CHARS = 1800; const OPENAI_QUICKSILVER_CONTEXT_MAX_ENTRIES = 16; const OPENAI_QUICKSILVER_CONTEXT_MAX_ITEM_CHARS = 800; const OPENAI_QUICKSILVER_CONTEXT_MAX_UTF8_BYTES = 8e3; const OPENAI_QUICKSILVER_CALL_URL = "https://api.openai.com/v1/live"; const OPENAI_CHATGPT_QUICKSILVER_CALL_URL = "https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas"; const OPENAI_REALTIME_CALL_URL = "https://api.openai.com/v1/realtime/calls"; const OPENAI_REALTIME_ERROR_BODY_MAX_BYTES = 16384; const OPENAI_REALTIME_ERROR_DETAIL_MAX_CHARS = 500; const OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES = 262144; const OPENAI_REALTIME_LOCATION_MAX_BYTES = 512; const OPENAI_REALTIME_CALL_ID_RE = /^[A-Za-z0-9_-]{1,128}$/u; const OPENAI_GPT_LIVE_WAITLIST_URL = "https://openai.com/form/gpt-live-1-in-the-api/"; function redactOpenAIRealtimeErrorDetail(text, auth, redactSensitiveText) { let redacted = text; const exactSecrets = [auth.token, auth.type === "oauth" ? auth.accountId : void 0]; for (const secret of exactSecrets) if (secret) redacted = redacted.split(secret).join("[REDACTED]"); return redactSensitiveText(redacted, { mode: "tools" }); } var OpenAIQuicksilverCallError = class extends Error { constructor(message, status) { super(message); this.status = status; this.name = "OpenAIQuicksilverCallError"; } }; function buildOpenAIQuicksilverSession(params) { const history = boundOpenAIQuicksilverContextItems(params.initialItems ?? []); const instructions = [params.instructions?.trim(), params.hostControlsInput ? OPENAI_QUICKSILVER_HOST_CONTROL_INSTRUCTIONS : void 0].filter(Boolean).join("\n\n"); const initialItems = (params.hostControlsInput ? [] : history).map((item) => ({ type: "message", role: item.role, content: [{ type: item.role === "assistant" ? "output_text" : "input_text", text: item.text }] })); return { model: params.model, instructions: instructions + (params.hostControlsInput ? buildOpenAIQuicksilverBackgroundContext(history, OPENAI_QUICKSILVER_CONTEXT_MAX_UTF8_BYTES) : ""), audio: { output: { voice: resolveOpenAIQuicksilverVoice(params.voice) } }, delegation: params.hostControlsInput ? { type: "client", ack_filler: false } : { type: "client" }, ...initialItems && initialItems.length > 0 ? { initial_items: initialItems } : {} }; } /** Builds the direct Frameless Bidi WebSocket handshake used by Codex realtime v3. */ function buildOpenAIQuicksilverSessionUpdate(params) { const { model: _model, ...session } = buildOpenAIQuicksilverSession({ model: "direct-websocket", ...params }); return { type: "session.update", session }; } function buildOpenAIQuicksilverWebSocketUrl(model) { const url = new URL(OPENAI_QUICKSILVER_CALL_URL); url.protocol = "wss:"; url.searchParams.set("model", model); return url.toString(); } function truncateOpenAIQuicksilverContextText(text, maxBytes) { let result = ""; let bytes = 0; let characters = 0; for (const character of text) { const characterBytes = Buffer.byteLength(character, "utf8"); if (characters >= OPENAI_QUICKSILVER_CONTEXT_MAX_ITEM_CHARS || bytes + characterBytes > maxBytes) break; result += character; bytes += characterBytes; characters += 1; } return result; } function boundOpenAIQuicksilverContextItems(items) { let remainingBytes = OPENAI_QUICKSILVER_CONTEXT_MAX_UTF8_BYTES; const newestFirst = []; for (let index = items.length - 1; index >= 0 && newestFirst.length < OPENAI_QUICKSILVER_CONTEXT_MAX_ENTRIES; index -= 1) { const item = items[index]; if (!item || remainingBytes <= 0) continue; const text = truncateOpenAIQuicksilverContextText(item.text, remainingBytes); if (!text) continue; newestFirst.push({ role: item.role, text }); remainingBytes -= Buffer.byteLength(text, "utf8"); } return newestFirst.toReversed(); } function openAIQuicksilverAuthHeaders(auth, requestIds, runtime) { return openAIRealtimeAuthHeaders({ auth, requestIds, baseUrl: OPENAI_QUICKSILVER_CALL_URL, includeQuicksilverAlpha: true }, runtime); } function openAIRealtimeAuthHeaders(params, { resolveProviderRequestHeaders }) { return { ...resolveProviderRequestHeaders({ provider: "openai", baseUrl: params.baseUrl, capability: "audio", transport: "http", defaultHeaders: {} }) ?? {}, Authorization: `Bearer ${params.auth.token}`, ...params.includeQuicksilverAlpha ? { "OpenAI-Alpha": "quicksilver=v2" } : {}, "session-id": params.requestIds.sessionId, "thread-id": params.requestIds.threadId, "x-session-id": params.requestIds.realtimeSessionId, ...params.auth.type === "oauth" ? { "chatgpt-account-id": params.auth.accountId } : {} }; } function buildOpenAIQuicksilverMultipartBody(params) { const sessionJson = JSON.stringify(params.session); let boundary; do boundary = `openclaw-quicksilver-${randomBytes(18).toString("hex")}`; while (params.sdp.includes(boundary) || sessionJson.includes(boundary)); return { body: [ `--${boundary}\r\n`, "Content-Disposition: form-data; name=\"sdp\"\r\n", "Content-Type: application/sdp\r\n\r\n", params.sdp, "\r\n", `--${boundary}\r\n`, "Content-Disposition: form-data; name=\"session\"\r\n", "Content-Type: application/json\r\n\r\n", sessionJson, "\r\n", `--${boundary}--\r\n` ].join(""), contentType: `multipart/form-data; boundary=${boundary}` }; } function parseOpenAIRealtimeCallLocation(location) { if (!location) throw new Error("OpenAI Realtime call response is missing the Location header"); if (Buffer.byteLength(location, "utf8") > OPENAI_REALTIME_LOCATION_MAX_BYTES) throw new Error("OpenAI Realtime call response Location header is too large"); let url; try { url = new URL(location, OPENAI_REALTIME_CALL_URL); } catch { throw new Error("OpenAI Realtime call response Location header is invalid"); } if (url.origin !== "https://api.openai.com" || url.search || url.hash) throw new Error("OpenAI Realtime call response Location header has an unexpected target"); const match = /^\/v1\/realtime\/calls\/([^/]+)\/?$/u.exec(url.pathname); if (!match?.[1] || !OPENAI_REALTIME_CALL_ID_RE.test(match[1])) throw new Error("OpenAI Realtime call response Location header has no valid call id"); return match[1]; } function buildOpenAIRealtimeSidebandUrl(callId) { if (!OPENAI_REALTIME_CALL_ID_RE.test(callId)) throw new Error("OpenAI Realtime call id is invalid"); const url = new URL("wss://api.openai.com/v1/realtime"); url.searchParams.set("call_id", callId); return url.toString(); } function isOpenAIQuicksilverCallId(value) { return /^rtc_[\w-]+$/.test(value) || /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value); } function decodeOpenAIQuicksilverCallId(params) { const sessionId = params.openAiSessionId?.trim() ?? ""; if (!params.location) { if (isOpenAIQuicksilverCallId(sessionId)) return sessionId; throw new OpenAIQuicksilverCallError(sessionId ? "GPT-Live call response returned an invalid openai-session-id" : "GPT-Live call response missing Location and openai-session-id headers"); } let pathname; try { pathname = new URL(params.location, params.callUrl).pathname; } catch { if (isOpenAIQuicksilverCallId(sessionId)) return sessionId; throw new OpenAIQuicksilverCallError("GPT-Live call response returned an invalid Location"); } const callId = pathname.split("/").filter(Boolean).find(isOpenAIQuicksilverCallId); if (!callId) { if (isOpenAIQuicksilverCallId(sessionId)) return sessionId; throw new OpenAIQuicksilverCallError("GPT-Live call response Location has no valid call id"); } return callId; } function describeOpenAIQuicksilverCallError(status, detail, auth) { const normalized = detail.toLowerCase(); if (status === 403) return "GPT-Live rejected the session (403). Verify the selected OpenAI account, model, and GPT-Live voice; this response alone does not identify which was denied."; if (status === 400 && auth.type === "api-key" && (normalized.includes("model_not_found") || normalized.includes("does not exist or you do not have access"))) return `OpenAI Platform API-key access to /v1/live is waitlist-gated. Use a ChatGPT OAuth profile or request access at ${OPENAI_GPT_LIVE_WAITLIST_URL}`; if (status === 400 && normalized.includes("session.model") && normalized.includes("not allowed")) return "The GPT-Live model value is not permitted. Choose a supported GPT-Live model in Settings > Talk."; return `GPT-Live call creation failed (${status})${detail ? `: ${detail}` : ""}`; } async function createOpenAIQuicksilverCall(params, runtime) { const isGptLive = isOpenAIGptLiveModel(params.session.model); if (params.gaSideband && (isGptLive || params.auth.type !== "api-key")) throw new Error("OpenAI Realtime Gateway control requires a GA model and Platform API key"); const chatGptCall = isGptLive && params.auth.type === "oauth"; const callUrl = chatGptCall ? OPENAI_CHATGPT_QUICKSILVER_CALL_URL : isGptLive ? OPENAI_QUICKSILVER_CALL_URL : OPENAI_REALTIME_CALL_URL; const authHeaders = openAIRealtimeAuthHeaders({ auth: params.auth, requestIds: params.requestIds, baseUrl: callUrl, includeQuicksilverAlpha: isGptLive }, runtime); const payload = { sdp: params.sdp, session: params.session }; const requestBody = chatGptCall ? { body: JSON.stringify(payload), contentType: "application/json" } : buildOpenAIQuicksilverMultipartBody(payload); const response = await (params.fetchImpl ?? fetch)(callUrl, { method: "POST", headers: { ...authHeaders, "Content-Type": requestBody.contentType }, body: requestBody.body, signal: params.signal }); if (!response.ok) { const providerDetail = await readResponseTextPrefix(response, OPENAI_REALTIME_ERROR_BODY_MAX_BYTES).catch(() => void 0); const detail = providerDetail?.truncated ? "" : truncateUtf16Safe(redactOpenAIRealtimeErrorDetail(providerDetail?.text.trim() ?? "", params.auth, runtime.redactSensitiveText), OPENAI_REALTIME_ERROR_DETAIL_MAX_CHARS); throw new OpenAIQuicksilverCallError(isGptLive ? describeOpenAIQuicksilverCallError(response.status, detail, params.auth) : `OpenAI Realtime call creation failed (${response.status})${detail ? `: ${detail}` : ""}`, response.status); } let gaCallId; if (params.gaSideband) try { gaCallId = parseOpenAIRealtimeCallLocation(response.headers.get("Location")); params.onCallAllocated(gaCallId); } catch (error) { await response.body?.cancel().catch(() => void 0); throw error; } const answerSdp = await runtime.readProviderTextResponse(response, `${isGptLive ? "GPT-Live" : "OpenAI Realtime"} SDP answer`, { maxBytes: OPENAI_REALTIME_SDP_ANSWER_MAX_BYTES }); if (!answerSdp.trim()) throw new OpenAIQuicksilverCallError(`${isGptLive ? "GPT-Live" : "OpenAI Realtime"} call creation returned an empty SDP answer`, response.status); if (gaCallId) return { kind: "ga-sideband", status: response.status, answerSdp, callId: gaCallId, sidebandUrl: buildOpenAIRealtimeSidebandUrl(gaCallId) }; if (!isGptLive) return { kind: "ga-realtime", status: response.status, answerSdp }; const callId = decodeOpenAIQuicksilverCallId({ location: response.headers.get("Location"), openAiSessionId: response.headers.get("openai-session-id"), callUrl }); return { kind: "gpt-live", status: response.status, answerSdp, callId, sidebandUrl: `wss://api.openai.com/v1/live/${callId}` }; } async function hangupOpenAIRealtimeCall(params, { resolveProviderRequestHeaders }) { if (!OPENAI_REALTIME_CALL_ID_RE.test(params.callId)) throw new Error("OpenAI Realtime call id is invalid"); const url = `${OPENAI_REALTIME_CALL_URL}/${encodeURIComponent(params.callId)}/hangup`; const headers = resolveProviderRequestHeaders({ provider: "openai", baseUrl: url, capability: "audio", transport: "http", defaultHeaders: { Authorization: `Bearer ${params.apiKey}` } }) ?? { Authorization: `Bearer ${params.apiKey}` }; const response = await (params.fetchImpl ?? fetch)(url, { method: "POST", headers, signal: params.signal }); await response.body?.cancel().catch(() => void 0); if (!response.ok && response.status !== 404) throw new Error(`OpenAI Realtime call hangup failed (${response.status})`); } function chunkOpenAIQuicksilverAppendText(text) { if (Buffer.byteLength(text, "utf8") <= OPENAI_QUICKSILVER_APPEND_MAX_BYTES) return [text]; const chunks = []; let current = ""; let currentBytes = 0; for (const character of text) { const characterBytes = Buffer.byteLength(character, "utf8"); if (current && currentBytes + characterBytes > OPENAI_QUICKSILVER_APPEND_MAX_BYTES) { chunks.push(current); current = ""; currentBytes = 0; } current += character; currentBytes += characterBytes; } if (current) chunks.push(current); return chunks; } /** Bound completed delegation output while preserving under-limit text byte-for-byte. */ function boundOpenAIQuicksilverDelegationResult(text) { if (text.length <= OPENAI_QUICKSILVER_DELEGATION_RESULT_MAX_CHARS) return text; return `${truncateUtf16Safe(text, 1784).trimEnd()} [truncated]`; } //#endregion export { buildOpenAIQuicksilverWebSocketUrl as a, createOpenAIQuicksilverCall as c, buildOpenAIQuicksilverSessionUpdate as i, hangupOpenAIRealtimeCall as l, boundOpenAIQuicksilverDelegationResult as n, buildOpenAIRealtimeSidebandUrl as o, buildOpenAIQuicksilverSession as r, chunkOpenAIQuicksilverAppendText as s, boundOpenAIQuicksilverContextItems as t, openAIQuicksilverAuthHeaders as u };