@@ -15,7 +15,7 @@ const { deriveCopilotApiTarget, deriveGitHubApiTarget, deriveGitHubApiBasePath,
1515const { resolveOpenCodeRoute } = require ( './providers/opencode' ) ;
1616
1717// Core proxy functions that remain in server.js
18- const { proxyWebSocket, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES , buildModelsJson, writeModelsJson } = require ( './server' ) ;
18+ const { proxyWebSocket, httpProbe, validateApiKeys, keyValidationResults, resetKeyValidationState, fetchJson, extractModelIds, fetchStartupModels, reflectEndpoints, healthResponse, cachedModels, resetModelCacheState, makeModelBodyTransform, MODEL_ALIASES , buildModelsJson, writeModelsJson, createProviderServer } = require ( './server' ) ;
1919
2020describe ( 'normalizeApiTarget' , ( ) => {
2121 it ( 'should strip https:// prefix' , ( ) => {
@@ -2125,3 +2125,241 @@ describe('composeBodyTransforms', () => {
21252125 expect ( composed ( Buffer . from ( 'hello' ) ) ) . toBeNull ( ) ;
21262126 } ) ;
21272127} ) ;
2128+
2129+ // ── createProviderServer tests ────────────────────────────────────────────────
2130+ //
2131+ // Tests that verify the generic proxy server factory honours the ProviderAdapter
2132+ // interface: health routing, unconfigured-stub responses, URL transforms, and
2133+ // adapter-specific auth selection.
2134+ //
2135+ describe ( 'createProviderServer' , ( ) => {
2136+ const servers = [ ] ;
2137+
2138+ /** Small helper: start a createProviderServer instance and return its port. */
2139+ function startAdapter ( adapter ) {
2140+ return new Promise ( ( resolve ) => {
2141+ const srv = createProviderServer ( adapter ) ;
2142+ srv . listen ( 0 , '127.0.0.1' , ( ) => {
2143+ servers . push ( srv ) ;
2144+ resolve ( srv . address ( ) . port ) ;
2145+ } ) ;
2146+ } ) ;
2147+ }
2148+
2149+ /** Fetch a path from a server running on localhost and return { status, body }. */
2150+ function fetch ( port , path , opts = { } ) {
2151+ return new Promise ( ( resolve , reject ) => {
2152+ const req = http . request (
2153+ { hostname : '127.0.0.1' , port, path, method : opts . method || 'GET' , headers : opts . headers || { } } ,
2154+ ( res ) => {
2155+ let data = '' ;
2156+ res . on ( 'data' , ( c ) => { data += c ; } ) ;
2157+ res . on ( 'end' , ( ) => {
2158+ let parsed ;
2159+ try { parsed = JSON . parse ( data ) ; } catch { parsed = data ; }
2160+ resolve ( { status : res . statusCode , body : parsed , headers : res . headers } ) ;
2161+ } ) ;
2162+ }
2163+ ) ;
2164+ req . on ( 'error' , reject ) ;
2165+ if ( opts . body ) req . write ( opts . body ) ;
2166+ req . end ( ) ;
2167+ } ) ;
2168+ }
2169+
2170+ afterEach ( ( done ) => {
2171+ let remaining = servers . length ;
2172+ if ( ! remaining ) { done ( ) ; return ; }
2173+ servers . splice ( 0 ) . forEach ( ( s ) => s . close ( ( ) => { if ( ! -- remaining ) done ( ) ; } ) ) ;
2174+ } ) ;
2175+
2176+ // ── /health endpoint — enabled adapter ──────────────────────────────────────
2177+
2178+ it ( 'returns 200 /health when adapter is enabled' , async ( ) => {
2179+ const adapter = {
2180+ name : 'test-enabled' , port : 0 , isManagementPort : false , alwaysBind : false ,
2181+ participatesInValidation : false ,
2182+ isEnabled : ( ) => true ,
2183+ getTargetHost : ( ) => 'api.example.com' ,
2184+ getBasePath : ( ) => '' ,
2185+ getAuthHeaders : ( ) => ( { } ) ,
2186+ getBodyTransform : ( ) => null ,
2187+ } ;
2188+ const port = await startAdapter ( adapter ) ;
2189+ const { status, body } = await fetch ( port , '/health' ) ;
2190+ expect ( status ) . toBe ( 200 ) ;
2191+ expect ( body . status ) . toBe ( 'healthy' ) ;
2192+ expect ( body . service ) . toBe ( 'awf-api-proxy-test-enabled' ) ;
2193+ } ) ;
2194+
2195+ // ── /health endpoint — disabled adapter (default 503) ───────────────────────
2196+
2197+ it ( 'returns default 503 /health when adapter is disabled and has no getUnconfiguredHealthResponse' , async ( ) => {
2198+ const adapter = {
2199+ name : 'test-disabled' , port : 0 , isManagementPort : false , alwaysBind : true ,
2200+ participatesInValidation : false ,
2201+ isEnabled : ( ) => false ,
2202+ getTargetHost : ( ) => '' ,
2203+ getBasePath : ( ) => '' ,
2204+ getAuthHeaders : ( ) => ( { } ) ,
2205+ getBodyTransform : ( ) => null ,
2206+ getUnconfiguredResponse : ( ) => ( { statusCode : 503 , body : { error : 'not configured' } } ) ,
2207+ } ;
2208+ const port = await startAdapter ( adapter ) ;
2209+ const { status, body } = await fetch ( port , '/health' ) ;
2210+ expect ( status ) . toBe ( 503 ) ;
2211+ expect ( body . status ) . toBe ( 'not_configured' ) ;
2212+ expect ( body . service ) . toBe ( 'awf-api-proxy-test-disabled' ) ;
2213+ } ) ;
2214+
2215+ // ── /health endpoint — custom unconfigured health response ──────────────────
2216+
2217+ it ( 'returns custom getUnconfiguredHealthResponse when adapter is disabled' , async ( ) => {
2218+ const adapter = {
2219+ name : 'test-custom-health' , port : 0 , isManagementPort : false , alwaysBind : true ,
2220+ participatesInValidation : false ,
2221+ isEnabled : ( ) => false ,
2222+ getTargetHost : ( ) => '' ,
2223+ getBasePath : ( ) => '' ,
2224+ getAuthHeaders : ( ) => ( { } ) ,
2225+ getBodyTransform : ( ) => null ,
2226+ getUnconfiguredResponse : ( ) => ( { statusCode : 503 , body : { error : 'not configured' } } ) ,
2227+ getUnconfiguredHealthResponse : ( ) => ( {
2228+ statusCode : 503 ,
2229+ body : { status : 'not_configured' , service : 'awf-api-proxy-gemini' , error : 'GEMINI_API_KEY not configured' } ,
2230+ } ) ,
2231+ } ;
2232+ const port = await startAdapter ( adapter ) ;
2233+ const { status, body } = await fetch ( port , '/health' ) ;
2234+ expect ( status ) . toBe ( 503 ) ;
2235+ expect ( body . service ) . toBe ( 'awf-api-proxy-gemini' ) ;
2236+ expect ( body . error ) . toMatch ( / G E M I N I _ A P I _ K E Y / ) ;
2237+ } ) ;
2238+
2239+ // ── Unconfigured stub — non-health request ────────────────────────────────
2240+
2241+ it ( 'returns getUnconfiguredResponse body for proxy requests when disabled' , async ( ) => {
2242+ const adapter = {
2243+ name : 'test-unconfigured' , port : 0 , isManagementPort : false , alwaysBind : true ,
2244+ participatesInValidation : false ,
2245+ isEnabled : ( ) => false ,
2246+ getTargetHost : ( ) => '' ,
2247+ getBasePath : ( ) => '' ,
2248+ getAuthHeaders : ( ) => ( { } ) ,
2249+ getBodyTransform : ( ) => null ,
2250+ getUnconfiguredResponse : ( ) => ( {
2251+ statusCode : 503 ,
2252+ body : { error : 'proxy not configured (no API key)' } ,
2253+ } ) ,
2254+ } ;
2255+ const port = await startAdapter ( adapter ) ;
2256+ const { status, body } = await fetch ( port , '/v1/chat/completions' , { method : 'POST' , body : '{}' } ) ;
2257+ expect ( status ) . toBe ( 503 ) ;
2258+ expect ( body . error ) . toMatch ( / p r o x y n o t c o n f i g u r e d / ) ;
2259+ } ) ;
2260+
2261+ it ( 'returns default 503 for proxy requests when disabled and no getUnconfiguredResponse' , async ( ) => {
2262+ const adapter = {
2263+ name : 'test-no-stub' , port : 0 , isManagementPort : false , alwaysBind : false ,
2264+ participatesInValidation : false ,
2265+ isEnabled : ( ) => false ,
2266+ getTargetHost : ( ) => '' ,
2267+ getBasePath : ( ) => '' ,
2268+ getAuthHeaders : ( ) => ( { } ) ,
2269+ getBodyTransform : ( ) => null ,
2270+ } ;
2271+ const port = await startAdapter ( adapter ) ;
2272+ const { status, body } = await fetch ( port , '/v1/models' , { method : 'GET' } ) ;
2273+ expect ( status ) . toBe ( 503 ) ;
2274+ expect ( body . error ) . toMatch ( / t e s t - n o - s t u b .* n o t c o n f i g u r e d / ) ;
2275+ } ) ;
2276+
2277+ // ── URL transform ─────────────────────────────────────────────────────────
2278+
2279+ it ( 'applies transformRequestUrl before proxying' , async ( ) => {
2280+ // Record what the transform was called with; upstream will fail (no real host)
2281+ // but the transform runs synchronously in the request handler before proxying starts.
2282+ const calls = [ ] ;
2283+ const adapter = {
2284+ name : 'test-url-transform' , port : 0 , isManagementPort : false , alwaysBind : false ,
2285+ participatesInValidation : false ,
2286+ isEnabled : ( ) => true ,
2287+ getTargetHost : ( ) => 'api.example.com' ,
2288+ getBasePath : ( ) => '' ,
2289+ getAuthHeaders : ( ) => ( { } ) ,
2290+ getBodyTransform : ( ) => null ,
2291+ transformRequestUrl : ( url ) => {
2292+ const result = url . replace ( '?key=placeholder' , '' ) ;
2293+ calls . push ( { input : url , output : result } ) ;
2294+ return result ;
2295+ } ,
2296+ } ;
2297+ const port = await startAdapter ( adapter ) ;
2298+ // fetch will return a non-2xx (proxy can't reach api.example.com in test), that's fine.
2299+ await fetch ( port , '/v1/models?key=placeholder' ) . catch ( ( ) => { } ) ;
2300+ expect ( calls ) . toHaveLength ( 1 ) ;
2301+ expect ( calls [ 0 ] . input ) . toBe ( '/v1/models?key=placeholder' ) ;
2302+ expect ( calls [ 0 ] . output ) . toBe ( '/v1/models' ) ;
2303+ } ) ;
2304+
2305+ // ── Auth headers ──────────────────────────────────────────────────────────
2306+
2307+ it ( 'calls getAuthHeaders() for each proxied request' , async ( ) => {
2308+ // Record the headers returned by getAuthHeaders; upstream will fail (no real host)
2309+ // but getAuthHeaders is called synchronously in the request handler.
2310+ const headerCalls = [ ] ;
2311+ const adapter = {
2312+ name : 'test-auth' , port : 0 , isManagementPort : false , alwaysBind : false ,
2313+ participatesInValidation : false ,
2314+ isEnabled : ( ) => true ,
2315+ getTargetHost : ( ) => 'api.example.com' ,
2316+ getBasePath : ( ) => '' ,
2317+ getAuthHeaders : ( req ) => {
2318+ const h = { 'Authorization' : 'Bearer injected-token' } ;
2319+ headerCalls . push ( h ) ;
2320+ return h ;
2321+ } ,
2322+ getBodyTransform : ( ) => null ,
2323+ } ;
2324+ const port = await startAdapter ( adapter ) ;
2325+ await fetch ( port , '/v1/models' ) . catch ( ( ) => { } ) ;
2326+ expect ( headerCalls ) . toHaveLength ( 1 ) ;
2327+ expect ( headerCalls [ 0 ] . Authorization ) . toBe ( 'Bearer injected-token' ) ;
2328+ } ) ;
2329+
2330+ // ── getBodyTransform called once per request (not per-call) ──────────────
2331+
2332+ it ( 'calls getBodyTransform() once per request' , async ( ) => {
2333+ let callCount = 0 ;
2334+ const upstream = http . createServer ( ( req , res ) => {
2335+ req . resume ( ) ;
2336+ res . writeHead ( 200 , { 'Content-Type' : 'application/json' } ) ;
2337+ res . end ( '{}' ) ;
2338+ } ) ;
2339+ const upstreamPort = await new Promise ( ( resolve ) => {
2340+ upstream . listen ( 0 , '127.0.0.1' , ( ) => resolve ( upstream . address ( ) . port ) ) ;
2341+ } ) ;
2342+ servers . push ( upstream ) ;
2343+
2344+ const adapter = {
2345+ name : 'test-transform-count' , port : 0 , isManagementPort : false , alwaysBind : false ,
2346+ participatesInValidation : false ,
2347+ isEnabled : ( ) => true ,
2348+ getTargetHost : ( ) => `127.0.0.1:${ upstreamPort } ` ,
2349+ getBasePath : ( ) => '' ,
2350+ getAuthHeaders : ( ) => ( { } ) ,
2351+ getBodyTransform : ( ) => { callCount ++ ; return null ; } ,
2352+ } ;
2353+ const port = await startAdapter ( adapter ) ;
2354+
2355+ await new Promise ( ( resolve , reject ) => {
2356+ const req = http . request ( { hostname : '127.0.0.1' , port, path : '/v1/chat/completions' , method : 'POST' } , resolve ) ;
2357+ req . on ( 'error' , reject ) ;
2358+ req . write ( '{}' ) ;
2359+ req . end ( ) ;
2360+ } ) ;
2361+
2362+ await new Promise ( ( r ) => setTimeout ( r , 100 ) ) ;
2363+ expect ( callCount ) . toBe ( 1 ) ;
2364+ } ) ;
2365+ } ) ;
0 commit comments