
























@@ -6,11 +6,16 @@ import { GoogleGenAI, Modality } from "@google/genai";
66import { chromium, type Browser } from "playwright";
77import { createServer } from "vite";
88import { buildOpenAIRealtimeVoiceProvider } from "../../extensions/openai/realtime-voice-provider.ts";
9-import { previewForDevToolLog, redactJsonValueForDevToolLog } from "../lib/dev-tooling-safety.ts";
9+import {
10+parseStrictIntegerOption,
11+previewForDevToolLog,
12+redactJsonValueForDevToolLog,
13+} from "../lib/dev-tooling-safety.ts";
10141115const OPENAI_REALTIME_MODEL =
1216process.env.OPENCLAW_REALTIME_OPENAI_MODEL?.trim() || "gpt-realtime-2";
1317const OPENAI_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_OPENAI_VOICE?.trim() || "alloy";
18+const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000;
1419const GOOGLE_REALTIME_MODEL =
1520process.env.OPENCLAW_REALTIME_GOOGLE_MODEL?.trim() ||
1621"gemini-2.5-flash-native-audio-preview-12-2025";
@@ -24,6 +29,17 @@ type SmokeResult = {
2429details?: Record<string, unknown>;
2530};
263132+type TimeoutOptions<T> = {
33+label: string;
34+timeoutMs: number;
35+run: (signal: AbortSignal) => Promise<T>;
36+};
37+38+type OpenAIHttpOptions = {
39+fetchImpl?: typeof fetch;
40+timeoutMs?: number;
41+};
42+2743function getEnv(name: string): string | undefined {
2844const value = process.env[name]?.trim();
2945return value ? value : undefined;
@@ -38,6 +54,36 @@ async function readBoundedText(response: Response): Promise<string> {
3854return previewForDevToolLog(text, 600);
3955}
405657+function resolveOpenAIHttpTimeoutMs(
58+raw = process.env.OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS,
59+): number {
60+return parseStrictIntegerOption({
61+fallback: DEFAULT_OPENAI_HTTP_TIMEOUT_MS,
62+label: "OPENCLAW_REALTIME_OPENAI_HTTP_TIMEOUT_MS",
63+min: 1,
64+ raw,
65+});
66+}
67+68+async function withTimeout<T>(options: TimeoutOptions<T>): Promise<T> {
69+const controller = new AbortController();
70+let timeout: ReturnType<typeof setTimeout> | undefined;
71+const timeoutPromise = new Promise<T>((_resolve, reject) => {
72+timeout = setTimeout(() => {
73+const error = new Error(`${options.label} exceeded timeout of ${options.timeoutMs}ms`);
74+reject(error);
75+controller.abort(error);
76+}, options.timeoutMs);
77+});
78+try {
79+return await Promise.race([options.run(controller.signal), timeoutPromise]);
80+} finally {
81+if (timeout) {
82+clearTimeout(timeout);
83+}
84+}
85+}
86+4187function printResult(result: SmokeResult): void {
4288console.log(
4389`${result.name}: ${result.ok ? "ok" : "failed"}`,
@@ -49,31 +95,43 @@ function compareStrings(left: string | undefined, right: string | undefined): nu
4995return (left ?? "").localeCompare(right ?? "");
5096}
519752-async function createOpenAIClientSecret(apiKey: string): Promise<string> {
53-const response = await fetch("https://api.openai.com/v1/realtime/client_secrets", {
54-method: "POST",
55-headers: {
56-Authorization: `Bearer ${apiKey}`,
57-"Content-Type": "application/json",
58-},
59-body: JSON.stringify({
60-session: {
61-type: "realtime",
62-model: OPENAI_REALTIME_MODEL,
63-audio: {
64-output: { voice: OPENAI_REALTIME_VOICE },
98+async function createOpenAIClientSecret(
99+apiKey: string,
100+options: OpenAIHttpOptions = {},
101+): Promise<string> {
102+const fetchImpl = options.fetchImpl ?? fetch;
103+const timeoutMs = options.timeoutMs ?? resolveOpenAIHttpTimeoutMs();
104+const payload = await withTimeout({
105+label: "OpenAI Realtime client secret request",
106+ timeoutMs,
107+run: async (signal) => {
108+const response = await fetchImpl("https://api.openai.com/v1/realtime/client_secrets", {
109+method: "POST",
110+headers: {
111+Authorization: `Bearer ${apiKey}`,
112+"Content-Type": "application/json",
65113},
66-},
67-}),
114+body: JSON.stringify({
115+session: {
116+type: "realtime",
117+model: OPENAI_REALTIME_MODEL,
118+audio: {
119+output: { voice: OPENAI_REALTIME_VOICE },
120+},
121+},
122+}),
123+ signal,
124+});
125+if (!response.ok) {
126+throw new Error(
127+`OpenAI Realtime client secret failed (${response.status}): ${await readBoundedText(
128+ response,
129+ )}`,
130+);
131+}
132+return (await response.json()) as Record<string, unknown>;
133+},
68134});
69-if (!response.ok) {
70-throw new Error(
71-`OpenAI Realtime client secret failed (${response.status}): ${await readBoundedText(
72- response,
73- )}`,
74-);
75-}
76-const payload = (await response.json()) as Record<string, unknown>;
77135const nested =
78136payload.client_secret && typeof payload.client_secret === "object"
79137 ? (payload.client_secret as Record<string, unknown>)
@@ -128,79 +186,118 @@ async function smokeOpenAIBackendBridge(apiKey: string): Promise<SmokeResult> {
128186129187async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<SmokeResult> {
130188try {
131-const clientSecret = await createOpenAIClientSecret(apiKey);
189+const openAIHttpTimeoutMs = resolveOpenAIHttpTimeoutMs();
190+const clientSecret = await createOpenAIClientSecret(apiKey, { timeoutMs: openAIHttpTimeoutMs });
132191const context = await browser.newContext({
133192permissions: ["microphone"],
134193});
135-const page = await context.newPage();
136-const result = await page.evaluate(
137-async ({ clientSecret: secret }) => {
138-let media: MediaStream;
139-if (navigator.mediaDevices?.getUserMedia) {
140-media = await navigator.mediaDevices.getUserMedia({ audio: true });
141-} else {
142-const audioContext = new AudioContext();
143-const destination = audioContext.createMediaStreamDestination();
144-const oscillator = audioContext.createOscillator();
145-oscillator.connect(destination);
146-oscillator.start();
147-media = destination.stream;
148-}
149-const peer = new RTCPeerConnection();
150-for (const track of media.getAudioTracks()) {
151-peer.addTrack(track, media);
152-}
153-const channel = peer.createDataChannel("oai-events");
154-const connectionState = new Promise<string>((resolve) => {
155-const timeout = window.setTimeout(() => resolve(peer.connectionState), 12_000);
156-peer.addEventListener("connectionstatechange", () => {
157-if (peer.connectionState === "connected" || peer.connectionState === "failed") {
158-window.clearTimeout(timeout);
159-resolve(peer.connectionState);
194+try {
195+const page = await context.newPage();
196+await page.evaluate("globalThis.__name = (fn) => fn");
197+const result = await page.evaluate(
198+async ({ clientSecret: secret, timeoutMs }) => {
199+const withBrowserTimeout = async <T>(
200+label: string,
201+run: (signal: AbortSignal) => Promise<T>,
202+): Promise<T> => {
203+const controller = new AbortController();
204+let timeout: number | undefined;
205+const timeoutPromise = new Promise<T>((_resolve, reject) => {
206+timeout = window.setTimeout(() => {
207+const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`);
208+reject(error);
209+controller.abort(error);
210+}, timeoutMs);
211+});
212+try {
213+return await Promise.race([run(controller.signal), timeoutPromise]);
214+} finally {
215+if (timeout !== undefined) {
216+window.clearTimeout(timeout);
217+}
160218}
161-});
162-channel.addEventListener("open", () => {
163-window.clearTimeout(timeout);
164-resolve(peer.connectionState || "data-channel-open");
165-});
166-});
167-const offer = await peer.createOffer();
168-await peer.setLocalDescription(offer);
169-const response = await fetch("https://api.openai.com/v1/realtime/calls", {
170-method: "POST",
171-body: offer.sdp,
172-headers: {
173-Authorization: `Bearer ${secret}`,
174-"Content-Type": "application/sdp",
175-},
176-});
177-if (!response.ok) {
178-throw new Error(`OpenAI Realtime SDP offer failed (${response.status})`);
179-}
180-const answer = await response.text();
181-await peer.setRemoteDescription({ type: "answer", sdp: answer });
182-const state = await connectionState;
183-peer.close();
184-media.getTracks().forEach((track) => track.stop());
185-return {
186-answerHasAudio: answer.includes("m=audio"),
187-remoteDescriptionApplied: peer.remoteDescription?.type === "answer",
188-connectionState: state,
189-};
190-},
191-{ clientSecret },
192-);
193-await context.close();
194-return {
195-name: "openai-webrtc-browser",
196-ok: result.answerHasAudio && result.remoteDescriptionApplied,
197-details: {
198-model: OPENAI_REALTIME_MODEL,
199-answerHasAudio: result.answerHasAudio,
200-remoteDescriptionApplied: result.remoteDescriptionApplied,
201-connectionState: result.connectionState,
202-},
203-};
219+};
220+let media: MediaStream | undefined;
221+let peer: RTCPeerConnection | undefined;
222+try {
223+if (navigator.mediaDevices?.getUserMedia) {
224+media = await navigator.mediaDevices.getUserMedia({ audio: true });
225+} else {
226+const audioContext = new AudioContext();
227+const destination = audioContext.createMediaStreamDestination();
228+const oscillator = audioContext.createOscillator();
229+oscillator.connect(destination);
230+oscillator.start();
231+media = destination.stream;
232+}
233+peer = new RTCPeerConnection();
234+for (const track of media.getAudioTracks()) {
235+peer.addTrack(track, media);
236+}
237+const channel = peer.createDataChannel("oai-events");
238+const connectionState = new Promise<string>((resolve) => {
239+const timeout = window.setTimeout(
240+() => resolve(peer?.connectionState ?? "timeout"),
241+12_000,
242+);
243+peer?.addEventListener("connectionstatechange", () => {
244+if (peer?.connectionState === "connected" || peer?.connectionState === "failed") {
245+window.clearTimeout(timeout);
246+resolve(peer.connectionState);
247+}
248+});
249+channel.addEventListener("open", () => {
250+window.clearTimeout(timeout);
251+resolve(peer?.connectionState || "data-channel-open");
252+});
253+});
254+const offer = await peer.createOffer();
255+await peer.setLocalDescription(offer);
256+const answer = await withBrowserTimeout(
257+"OpenAI Realtime SDP offer request",
258+async (signal) => {
259+const response = await fetch("https://api.openai.com/v1/realtime/calls", {
260+method: "POST",
261+body: offer.sdp,
262+headers: {
263+Authorization: `Bearer ${secret}`,
264+"Content-Type": "application/sdp",
265+},
266+ signal,
267+});
268+if (!response.ok) {
269+throw new Error(`OpenAI Realtime SDP offer failed (${response.status})`);
270+}
271+return await response.text();
272+},
273+);
274+await peer.setRemoteDescription({ type: "answer", sdp: answer });
275+const state = await connectionState;
276+return {
277+answerHasAudio: answer.includes("m=audio"),
278+remoteDescriptionApplied: peer.remoteDescription?.type === "answer",
279+connectionState: state,
280+};
281+} finally {
282+peer?.close();
283+media?.getTracks().forEach((track) => track.stop());
284+}
285+},
286+{ clientSecret, timeoutMs: openAIHttpTimeoutMs },
287+);
288+return {
289+name: "openai-webrtc-browser",
290+ok: result.answerHasAudio && result.remoteDescriptionApplied,
291+details: {
292+model: OPENAI_REALTIME_MODEL,
293+answerHasAudio: result.answerHasAudio,
294+remoteDescriptionApplied: result.remoteDescriptionApplied,
295+connectionState: result.connectionState,
296+},
297+};
298+} finally {
299+await context.close();
300+}
204301} catch (error) {
205302return { name: "openai-webrtc-browser", ok: false, details: { error: shortError(error) } };
206303}
@@ -578,3 +675,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
578675process.exitCode = 1;
579676});
580677}
678+679+export const testing = {
680+ createOpenAIClientSecret,
681+ resolveOpenAIHttpTimeoutMs,
682+};
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。