mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
fix(dev): bound realtime smoke HTTP waits
This commit is contained in:
@@ -6,11 +6,16 @@ import { GoogleGenAI, Modality } from "@google/genai";
|
||||
import { chromium, type Browser } from "playwright";
|
||||
import { createServer } from "vite";
|
||||
import { buildOpenAIRealtimeVoiceProvider } from "../../extensions/openai/realtime-voice-provider.ts";
|
||||
import { previewForDevToolLog, redactJsonValueForDevToolLog } from "../lib/dev-tooling-safety.ts";
|
||||
import {
|
||||
parseStrictIntegerOption,
|
||||
previewForDevToolLog,
|
||||
redactJsonValueForDevToolLog,
|
||||
} from "../lib/dev-tooling-safety.ts";
|
||||
|
||||
const OPENAI_REALTIME_MODEL =
|
||||
process.env.OPENCLAW_REALTIME_OPENAI_MODEL?.trim() || "gpt-realtime-2";
|
||||
const OPENAI_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_OPENAI_VOICE?.trim() || "alloy";
|
||||
const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000;
|
||||
const GOOGLE_REALTIME_MODEL =
|
||||
process.env.OPENCLAW_REALTIME_GOOGLE_MODEL?.trim() ||
|
||||
"gemini-2.5-flash-native-audio-preview-12-2025";
|
||||
@@ -24,6 +29,17 @@ type SmokeResult = {
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type TimeoutOptions<T> = {
|
||||
label: string;
|
||||
timeoutMs: number;
|
||||
run: (signal: AbortSignal) => Promise<T>;
|
||||
};
|
||||
|
||||
type OpenAIHttpOptions = {
|
||||
fetchImpl?: typeof fetch;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function getEnv(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
return value ? value : undefined;
|
||||
@@ -38,6 +54,36 @@ async function readBoundedText(response: Response): Promise<string> {
|
||||
return previewForDevToolLog(text, 600);
|
||||
}
|
||||
|
||||
function resolveOpenAIHttpTimeoutMs(
|
||||
raw = process.env.OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS,
|
||||
): number {
|
||||
return parseStrictIntegerOption({
|
||||
fallback: DEFAULT_OPENAI_HTTP_TIMEOUT_MS,
|
||||
label: "OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS",
|
||||
min: 1,
|
||||
raw,
|
||||
});
|
||||
}
|
||||
|
||||
async function withTimeout<T>(options: TimeoutOptions<T>): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<T>((_resolve, reject) => {
|
||||
timeout = setTimeout(() => {
|
||||
const error = new Error(`${options.label} exceeded timeout of ${options.timeoutMs}ms`);
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, options.timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([options.run(controller.signal), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout) {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function printResult(result: SmokeResult): void {
|
||||
console.log(
|
||||
`${result.name}: ${result.ok ? "ok" : "failed"}`,
|
||||
@@ -49,31 +95,43 @@ function compareStrings(left: string | undefined, right: string | undefined): nu
|
||||
return (left ?? "").localeCompare(right ?? "");
|
||||
}
|
||||
|
||||
async function createOpenAIClientSecret(apiKey: string): Promise<string> {
|
||||
const response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
session: {
|
||||
type: "realtime",
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
audio: {
|
||||
output: { voice: OPENAI_REALTIME_VOICE },
|
||||
async function createOpenAIClientSecret(
|
||||
apiKey: string,
|
||||
options: OpenAIHttpOptions = {},
|
||||
): Promise<string> {
|
||||
const fetchImpl = options.fetchImpl ?? fetch;
|
||||
const timeoutMs = options.timeoutMs ?? resolveOpenAIHttpTimeoutMs();
|
||||
const payload = await withTimeout({
|
||||
label: "OpenAI Realtime client secret request",
|
||||
timeoutMs,
|
||||
run: async (signal) => {
|
||||
const response = await fetchImpl("https://api.openai.com/v1/realtime/client_secrets", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
session: {
|
||||
type: "realtime",
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
audio: {
|
||||
output: { voice: OPENAI_REALTIME_VOICE },
|
||||
},
|
||||
},
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OpenAI Realtime client secret failed (${response.status}): ${await readBoundedText(
|
||||
response,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
return (await response.json()) as Record<string, unknown>;
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`OpenAI Realtime client secret failed (${response.status}): ${await readBoundedText(
|
||||
response,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as Record<string, unknown>;
|
||||
const nested =
|
||||
payload.client_secret && typeof payload.client_secret === "object"
|
||||
? (payload.client_secret as Record<string, unknown>)
|
||||
@@ -128,79 +186,118 @@ async function smokeOpenAIBackendBridge(apiKey: string): Promise<SmokeResult> {
|
||||
|
||||
async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<SmokeResult> {
|
||||
try {
|
||||
const clientSecret = await createOpenAIClientSecret(apiKey);
|
||||
const openAIHttpTimeoutMs = resolveOpenAIHttpTimeoutMs();
|
||||
const clientSecret = await createOpenAIClientSecret(apiKey, { timeoutMs: openAIHttpTimeoutMs });
|
||||
const context = await browser.newContext({
|
||||
permissions: ["microphone"],
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const result = await page.evaluate(
|
||||
async ({ clientSecret: secret }) => {
|
||||
let media: MediaStream;
|
||||
if (navigator.mediaDevices?.getUserMedia) {
|
||||
media = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} else {
|
||||
const audioContext = new AudioContext();
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
oscillator.connect(destination);
|
||||
oscillator.start();
|
||||
media = destination.stream;
|
||||
}
|
||||
const peer = new RTCPeerConnection();
|
||||
for (const track of media.getAudioTracks()) {
|
||||
peer.addTrack(track, media);
|
||||
}
|
||||
const channel = peer.createDataChannel("oai-events");
|
||||
const connectionState = new Promise<string>((resolve) => {
|
||||
const timeout = window.setTimeout(() => resolve(peer.connectionState), 12_000);
|
||||
peer.addEventListener("connectionstatechange", () => {
|
||||
if (peer.connectionState === "connected" || peer.connectionState === "failed") {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(peer.connectionState);
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
await page.evaluate("globalThis.__name = (fn) => fn");
|
||||
const result = await page.evaluate(
|
||||
async ({ clientSecret: secret, timeoutMs }) => {
|
||||
const withBrowserTimeout = async <T>(
|
||||
label: string,
|
||||
run: (signal: AbortSignal) => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const controller = new AbortController();
|
||||
let timeout: number | undefined;
|
||||
const timeoutPromise = new Promise<T>((_resolve, reject) => {
|
||||
timeout = window.setTimeout(() => {
|
||||
const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`);
|
||||
reject(error);
|
||||
controller.abort(error);
|
||||
}, timeoutMs);
|
||||
});
|
||||
try {
|
||||
return await Promise.race([run(controller.signal), timeoutPromise]);
|
||||
} finally {
|
||||
if (timeout !== undefined) {
|
||||
window.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
});
|
||||
channel.addEventListener("open", () => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(peer.connectionState || "data-channel-open");
|
||||
});
|
||||
});
|
||||
const offer = await peer.createOffer();
|
||||
await peer.setLocalDescription(offer);
|
||||
const response = await fetch("https://api.openai.com/v1/realtime/calls", {
|
||||
method: "POST",
|
||||
body: offer.sdp,
|
||||
headers: {
|
||||
Authorization: `Bearer ${secret}`,
|
||||
"Content-Type": "application/sdp",
|
||||
},
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI Realtime SDP offer failed (${response.status})`);
|
||||
}
|
||||
const answer = await response.text();
|
||||
await peer.setRemoteDescription({ type: "answer", sdp: answer });
|
||||
const state = await connectionState;
|
||||
peer.close();
|
||||
media.getTracks().forEach((track) => track.stop());
|
||||
return {
|
||||
answerHasAudio: answer.includes("m=audio"),
|
||||
remoteDescriptionApplied: peer.remoteDescription?.type === "answer",
|
||||
connectionState: state,
|
||||
};
|
||||
},
|
||||
{ clientSecret },
|
||||
);
|
||||
await context.close();
|
||||
return {
|
||||
name: "openai-webrtc-browser",
|
||||
ok: result.answerHasAudio && result.remoteDescriptionApplied,
|
||||
details: {
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
answerHasAudio: result.answerHasAudio,
|
||||
remoteDescriptionApplied: result.remoteDescriptionApplied,
|
||||
connectionState: result.connectionState,
|
||||
},
|
||||
};
|
||||
};
|
||||
let media: MediaStream | undefined;
|
||||
let peer: RTCPeerConnection | undefined;
|
||||
try {
|
||||
if (navigator.mediaDevices?.getUserMedia) {
|
||||
media = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
} else {
|
||||
const audioContext = new AudioContext();
|
||||
const destination = audioContext.createMediaStreamDestination();
|
||||
const oscillator = audioContext.createOscillator();
|
||||
oscillator.connect(destination);
|
||||
oscillator.start();
|
||||
media = destination.stream;
|
||||
}
|
||||
peer = new RTCPeerConnection();
|
||||
for (const track of media.getAudioTracks()) {
|
||||
peer.addTrack(track, media);
|
||||
}
|
||||
const channel = peer.createDataChannel("oai-events");
|
||||
const connectionState = new Promise<string>((resolve) => {
|
||||
const timeout = window.setTimeout(
|
||||
() => resolve(peer?.connectionState ?? "timeout"),
|
||||
12_000,
|
||||
);
|
||||
peer?.addEventListener("connectionstatechange", () => {
|
||||
if (peer?.connectionState === "connected" || peer?.connectionState === "failed") {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(peer.connectionState);
|
||||
}
|
||||
});
|
||||
channel.addEventListener("open", () => {
|
||||
window.clearTimeout(timeout);
|
||||
resolve(peer?.connectionState || "data-channel-open");
|
||||
});
|
||||
});
|
||||
const offer = await peer.createOffer();
|
||||
await peer.setLocalDescription(offer);
|
||||
const answer = await withBrowserTimeout(
|
||||
"OpenAI Realtime SDP offer request",
|
||||
async (signal) => {
|
||||
const response = await fetch("https://api.openai.com/v1/realtime/calls", {
|
||||
method: "POST",
|
||||
body: offer.sdp,
|
||||
headers: {
|
||||
Authorization: `Bearer ${secret}`,
|
||||
"Content-Type": "application/sdp",
|
||||
},
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI Realtime SDP offer failed (${response.status})`);
|
||||
}
|
||||
return await response.text();
|
||||
},
|
||||
);
|
||||
await peer.setRemoteDescription({ type: "answer", sdp: answer });
|
||||
const state = await connectionState;
|
||||
return {
|
||||
answerHasAudio: answer.includes("m=audio"),
|
||||
remoteDescriptionApplied: peer.remoteDescription?.type === "answer",
|
||||
connectionState: state,
|
||||
};
|
||||
} finally {
|
||||
peer?.close();
|
||||
media?.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
},
|
||||
{ clientSecret, timeoutMs: openAIHttpTimeoutMs },
|
||||
);
|
||||
return {
|
||||
name: "openai-webrtc-browser",
|
||||
ok: result.answerHasAudio && result.remoteDescriptionApplied,
|
||||
details: {
|
||||
model: OPENAI_REALTIME_MODEL,
|
||||
answerHasAudio: result.answerHasAudio,
|
||||
remoteDescriptionApplied: result.remoteDescriptionApplied,
|
||||
connectionState: result.connectionState,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
} catch (error) {
|
||||
return { name: "openai-webrtc-browser", ok: false, details: { error: shortError(error) } };
|
||||
}
|
||||
@@ -578,3 +675,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
createOpenAIClientSecret,
|
||||
resolveOpenAIHttpTimeoutMs,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user