Skip to content

Commit fc637ee

Browse files
committed
test: cover x-model-base-url chatcompletions extract flow
1 parent 8b3ab26 commit fc637ee

2 files changed

Lines changed: 564 additions & 0 deletions

File tree

packages/server-v3/test/integration/v3/extract.test.ts

Lines changed: 282 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1+
import { spawn } from "node:child_process";
2+
import type { ChildProcessWithoutNullStreams } from "node:child_process";
13
import assert from "node:assert/strict";
4+
import { once } from "node:events";
5+
import fs from "node:fs/promises";
6+
import { createServer } from "node:http";
7+
import type { Server } from "node:http";
8+
import os from "node:os";
9+
import path from "node:path";
210
import { after, before, describe, it } from "node:test";
311

412
import {
@@ -22,6 +30,158 @@ import {
2230
/** Result type for extract SSE events */
2331
type ExtractResult = Record<string, unknown>;
2432

33+
type FakeChatServer = {
34+
server: Server;
35+
baseURL: string;
36+
requests: Array<{ url: string; authorization?: string }>;
37+
};
38+
39+
type LocalChromeHandle = {
40+
process: ChildProcessWithoutNullStreams;
41+
cdpUrl: string;
42+
userDataDir: string;
43+
};
44+
45+
async function startFakeChatCompletionsServer(): Promise<FakeChatServer> {
46+
const responses = [
47+
JSON.stringify({ title: "Example Domain" }),
48+
JSON.stringify({ completed: true, progress: "done" }),
49+
];
50+
const requests: Array<{ url: string; authorization?: string }> = [];
51+
52+
const server = createServer((req, res) => {
53+
requests.push({
54+
url: req.url ?? "",
55+
authorization: req.headers.authorization,
56+
});
57+
58+
const content = responses.shift();
59+
if (!content) {
60+
res.writeHead(500, { "content-type": "application/json" });
61+
res.end(JSON.stringify({ error: { message: "unexpected extra request" } }));
62+
return;
63+
}
64+
65+
req.resume();
66+
req.on("end", () => {
67+
res.writeHead(200, { "content-type": "application/json" });
68+
res.end(
69+
JSON.stringify({
70+
id: `chatcmpl-test-${requests.length}`,
71+
object: "chat.completion",
72+
created: 0,
73+
model: "glm-4-flash",
74+
choices: [
75+
{
76+
index: 0,
77+
message: {
78+
role: "assistant",
79+
content,
80+
},
81+
finish_reason: "stop",
82+
},
83+
],
84+
usage: {
85+
prompt_tokens: 1,
86+
completion_tokens: 1,
87+
total_tokens: 2,
88+
},
89+
}),
90+
);
91+
});
92+
});
93+
94+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
95+
const address = server.address();
96+
if (!address || typeof address === "string") {
97+
throw new Error("Failed to determine fake chat server address");
98+
}
99+
100+
return {
101+
server,
102+
baseURL: `http://127.0.0.1:${address.port}`,
103+
requests,
104+
};
105+
}
106+
107+
async function stopFakeChatCompletionsServer(server: Server): Promise<void> {
108+
await new Promise<void>((resolve, reject) => {
109+
server.close((error) => {
110+
if (error) {
111+
reject(error);
112+
return;
113+
}
114+
resolve();
115+
});
116+
});
117+
}
118+
119+
async function startLocalChromeWithCdp(): Promise<LocalChromeHandle> {
120+
const chromePath = process.env.CHROME_PATH;
121+
if (!chromePath) {
122+
throw new Error("CHROME_PATH must be set for the local CDP integration test");
123+
}
124+
125+
const userDataDir = await fs.mkdtemp(
126+
path.join(os.tmpdir(), "stagehand-cdp-v3-"),
127+
);
128+
const chrome = spawn(
129+
chromePath,
130+
[
131+
"--headless=new",
132+
"--disable-gpu",
133+
"--no-first-run",
134+
"--no-default-browser-check",
135+
`--user-data-dir=${userDataDir}`,
136+
"--remote-debugging-port=0",
137+
"about:blank",
138+
],
139+
{ stdio: ["ignore", "pipe", "pipe"] },
140+
);
141+
142+
const cdpUrl = await new Promise<string>((resolve, reject) => {
143+
const timeout = setTimeout(() => {
144+
reject(new Error("Timed out waiting for Chrome DevTools endpoint"));
145+
}, 15_000);
146+
147+
const onData = (chunk: Buffer) => {
148+
const text = chunk.toString("utf8");
149+
const match = text.match(/DevTools listening on (ws:\/\/[^\s]+)/);
150+
if (!match) return;
151+
152+
clearTimeout(timeout);
153+
chrome.stderr.off("data", onData);
154+
chrome.removeAllListeners("exit");
155+
resolve(match[1]);
156+
};
157+
158+
chrome.stderr.on("data", onData);
159+
chrome.once("exit", (code, signal) => {
160+
clearTimeout(timeout);
161+
chrome.stderr.off("data", onData);
162+
reject(
163+
new Error(
164+
`Chrome exited before exposing a DevTools endpoint (code=${code}, signal=${signal})`,
165+
),
166+
);
167+
});
168+
});
169+
170+
return {
171+
process: chrome,
172+
cdpUrl,
173+
userDataDir,
174+
};
175+
}
176+
177+
async function stopLocalChrome(handle: LocalChromeHandle): Promise<void> {
178+
if (handle.process.exitCode === null && !handle.process.killed) {
179+
handle.process.kill("SIGTERM");
180+
await once(handle.process, "exit").catch((): undefined => undefined);
181+
}
182+
await fs.rm(handle.userDataDir, { recursive: true, force: true });
183+
}
184+
25185
// Shared session for all extract tests (extract is read-only, safe to share)
26186
let sessionId: string;
27187
let cdpUrl: string;
@@ -333,6 +493,128 @@ describe("POST /v1/sessions/:id/extract (V3)", () => {
333493
ctx,
334494
);
335495
});
496+
497+
it("should use x-model-base-url for chatcompletions extract requests", async () => {
498+
const url = getBaseUrl();
499+
const fakeChatServer = await startFakeChatCompletionsServer();
500+
const localChrome = await startLocalChromeWithCdp();
501+
let customSessionId: string | undefined;
502+
503+
try {
504+
const headers = {
505+
...getHeaders("3.0.0"),
506+
"x-model-api-key": "test-key",
507+
"x-model-base-url": fakeChatServer.baseURL,
508+
};
509+
510+
interface StartResponse {
511+
success: boolean;
512+
data?: {
513+
sessionId: string;
514+
cdpUrl: string;
515+
available: boolean;
516+
};
517+
}
518+
519+
const startCtx = await fetchWithContext<StartResponse>(
520+
`${url}/v1/sessions/start`,
521+
{
522+
method: "POST",
523+
headers,
524+
body: JSON.stringify({
525+
modelName: "chatcompletions/glm-4-flash",
526+
browser: { type: "local", cdpUrl: localChrome.cdpUrl },
527+
}),
528+
},
529+
);
530+
531+
assertFetchStatus(startCtx, HTTP_OK, "Session start should succeed");
532+
assertFetchOk(startCtx.body !== null, "Start should have body", startCtx);
533+
assertFetchOk(
534+
Boolean(startCtx.body.success && startCtx.body.data?.sessionId),
535+
"Start should return a sessionId",
536+
startCtx,
537+
);
538+
539+
customSessionId = startCtx.body.data?.sessionId;
540+
assert.ok(customSessionId, "Expected a custom session id");
541+
542+
const navResponse = await navigateSession(
543+
customSessionId,
544+
"https://example.com",
545+
headers,
546+
);
547+
assert.equal(navResponse.status, HTTP_OK, "Navigate should succeed");
548+
549+
const frameId = await getMainFrameId(localChrome.cdpUrl);
550+
551+
interface ExtractResponse {
552+
success: boolean;
553+
data?: { result: Record<string, unknown>; actionId?: string };
554+
}
555+
556+
const extractCtx = await fetchWithContext<ExtractResponse>(
557+
`${url}/v1/sessions/${customSessionId}/extract`,
558+
{
559+
method: "POST",
560+
headers,
561+
body: JSON.stringify({
562+
instruction: "extract the page title",
563+
schema: {
564+
type: "object",
565+
properties: {
566+
title: { type: "string" },
567+
},
568+
required: ["title"],
569+
},
570+
frameId,
571+
}),
572+
},
573+
);
574+
575+
assertFetchStatus(
576+
extractCtx,
577+
HTTP_OK,
578+
"Extract through custom model base URL should succeed",
579+
);
580+
assertFetchOk(
581+
extractCtx.body !== null,
582+
"Extract should have response body",
583+
extractCtx,
584+
);
585+
assertFetchOk(
586+
extractCtx.body.success,
587+
"Extract should indicate success",
588+
extractCtx,
589+
);
590+
assert.equal(extractCtx.body.data?.result.title, "Example Domain");
591+
assert.equal(
592+
fakeChatServer.requests.length,
593+
2,
594+
"Expected extract + metadata requests to hit the fake server",
595+
);
596+
for (const request of fakeChatServer.requests) {
597+
assert.ok(
598+
request.url.endsWith("/chat/completions") ||
599+
request.url.endsWith("/v1/chat/completions"),
600+
`Unexpected request path: ${request.url}`,
601+
);
602+
assert.equal(request.authorization, "Bearer test-key");
603+
}
604+
} finally {
605+
if (customSessionId) {
606+
await endSession(customSessionId, {
607+
...getHeaders("3.0.0"),
608+
"x-model-api-key": "test-key",
609+
"x-model-base-url": fakeChatServer.baseURL,
610+
}).catch((): undefined => undefined);
611+
}
612+
await stopLocalChrome(localChrome).catch((): undefined => undefined);
613+
await stopFakeChatCompletionsServer(fakeChatServer.server).catch(
614+
(): undefined => undefined,
615+
);
616+
}
617+
});
336618
});
337619

338620
// =============================================================================

0 commit comments

Comments
 (0)