1+ import { spawn } from "node:child_process" ;
2+ import type { ChildProcessWithoutNullStreams } from "node:child_process" ;
13import 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" ;
210import { after , before , describe , it } from "node:test" ;
311
412import {
@@ -22,6 +30,158 @@ import {
2230/** Result type for extract SSE events */
2331type 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 ( / D e v T o o l s l i s t e n i n g o n ( w s : \/ \/ [ ^ \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)
26186let sessionId : string ;
27187let 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