From 79916694a8231d271d232cc2a3069b308ce3fbfc Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:14:50 +0100 Subject: [PATCH 01/11] feat(router): add explicit provider registry --- src/core/provider-router.ts | 166 ++++++++++++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 src/core/provider-router.ts diff --git a/src/core/provider-router.ts b/src/core/provider-router.ts new file mode 100644 index 00000000..3a3d8e84 --- /dev/null +++ b/src/core/provider-router.ts @@ -0,0 +1,166 @@ +import { createProxy, type ProxyConfig, type ProxyEvent } from './proxy.js'; + +export type ProviderProtocol = 'anthropic' | 'openai' | 'google'; + +export interface ProviderRouteDefinition { + /** Stable route id used in `/providers//...`. */ + id: string; + /** Wire protocol accepted by the configured upstream. */ + protocol: ProviderProtocol; + /** Existing proxy configuration for this provider. Kept in memory only. */ + proxy: ProxyConfig; +} + +export interface ProviderRouterConfig { + /** Handles legacy unprefixed routes such as `/v1/messages`. */ + defaultProxy: ProxyConfig; + /** Explicitly addressable providers. */ + providers: readonly ProviderRouteDefinition[]; + /** Optional observer invoked after the provider-specific observer. */ + onRequest?: (providerId: string, event: ProxyEvent) => void | Promise; +} + +export interface ParsedProviderRoute { + providerId: string; + upstreamPath: string; +} + +export interface ProviderRouterInspection { + defaultRoute: 'legacy'; + providers: Array<{ + id: string; + protocol: ProviderProtocol; + prefix: string; + }>; +} + +const PROVIDER_ID = /^[a-z][a-z0-9-]{0,62}$/; +const PREFIX = '/providers/'; + +export function assertProviderId(id: string): void { + if (!PROVIDER_ID.test(id)) { + throw new Error( + `invalid provider id ${JSON.stringify(id)}; expected lowercase letters, digits and dashes`, + ); + } +} + +/** + * Parse an explicit provider-prefixed request path. + * + * The provider id is never accepted from a header, query string or body. This + * keeps routing metadata outside the model payload and prevents an untrusted + * client from overriding upstream selection through a forwarded header. + */ +export function parseProviderRoute(pathname: string): ParsedProviderRoute | null { + if (!pathname.startsWith(PREFIX)) return null; + const remainder = pathname.slice(PREFIX.length); + const slash = remainder.indexOf('/'); + if (slash <= 0) return null; + + const providerId = remainder.slice(0, slash); + if (!PROVIDER_ID.test(providerId)) return null; + + const upstreamPath = remainder.slice(slash); + if (!upstreamPath.startsWith('/') || upstreamPath.startsWith('//')) return null; + return { providerId, upstreamPath }; +} + +function wrapProviderObserver( + definition: ProviderRouteDefinition, + routerObserver: ProviderRouterConfig['onRequest'], +): ProxyConfig { + const providerObserver = definition.proxy.onRequest; + return { + ...definition.proxy, + onRequest: async (event) => { + // Keep provider identity explicit even for generic OpenAI-compatible + // providers whose core handler would otherwise leave it unset. + event.provider ??= definition.id; + await providerObserver?.(event); + await routerObserver?.(definition.id, event); + }, + }; +} + +function rewriteProviderRequest(request: Request, route: ParsedProviderRoute): Request { + const sourceUrl = new URL(request.url); + sourceUrl.pathname = route.upstreamPath; + + const bodyAllowed = request.method !== 'GET' && request.method !== 'HEAD'; + const init: RequestInit & { duplex?: 'half' } = { + method: request.method, + headers: new Headers(request.headers), + body: bodyAllowed ? request.body : undefined, + redirect: request.redirect, + signal: request.signal, + }; + if (request.body) init.duplex = 'half'; + + // The body stream is forwarded without decoding or re-encoding it. Tool + // schemas, prompts, binary parts and structured-output contracts remain + // untouched until the selected provider's normal transform pipeline runs. + return new Request(sourceUrl, init); +} + +/** + * Create one request handler that multiplexes several provider-specific + * `createProxy` instances behind one Web-standard request handler. + * + * Legacy paths continue through `defaultProxy`. Explicit provider paths use: + * + * /providers// + */ +export function createProviderRouter( + config: ProviderRouterConfig, +): ((request: Request) => Promise) & { inspect(): ProviderRouterInspection } { + const defaultHandler = createProxy(config.defaultProxy); + const handlers = new Map>(); + const definitions = new Map(); + + for (const definition of config.providers) { + assertProviderId(definition.id); + if (definitions.has(definition.id)) { + throw new Error(`duplicate provider id: ${definition.id}`); + } + definitions.set(definition.id, definition); + handlers.set( + definition.id, + createProxy(wrapProviderObserver(definition, config.onRequest)), + ); + } + + const route = async (request: Request): Promise => { + const parsed = parseProviderRoute(new URL(request.url).pathname); + if (!parsed) return defaultHandler(request); + + const handler = handlers.get(parsed.providerId); + if (!handler) { + return new Response( + JSON.stringify({ + error: 'unknown_provider', + provider: parsed.providerId, + }), + { + status: 404, + headers: { 'content-type': 'application/json' }, + }, + ); + } + + return handler(rewriteProviderRequest(request, parsed)); + }; + + return Object.assign(route, { + inspect(): ProviderRouterInspection { + return { + defaultRoute: 'legacy', + providers: [...definitions.values()].map((definition) => ({ + id: definition.id, + protocol: definition.protocol, + prefix: `${PREFIX}${definition.id}`, + })), + }; + }, + }); +} From cf4dd6060bffb2c7f54205f35e297c645e1aa4e7 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:15:18 +0100 Subject: [PATCH 02/11] test(router): pin provider isolation and routing contracts --- tests/provider-router.test.ts | 234 ++++++++++++++++++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 tests/provider-router.test.ts diff --git a/tests/provider-router.test.ts b/tests/provider-router.test.ts new file mode 100644 index 00000000..d72769e8 --- /dev/null +++ b/tests/provider-router.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { + assertProviderId, + createProviderRouter, + parseProviderRoute, +} from '../src/core/provider-router.js'; + +function echoFetch(calls: Array<{ url: string; body: string; authorization: string | null }>): typeof fetch { + return vi.fn(async (input, init) => { + const request = input instanceof Request + ? input + : new Request(String(input), { + ...init, + ...(init?.body ? { duplex: 'half' as const } : {}), + }); + const body = request.method === 'GET' || request.method === 'HEAD' + ? '' + : await request.text(); + calls.push({ + url: request.url, + body, + authorization: request.headers.get('authorization'), + }); + return new Response(JSON.stringify({ url: request.url, body }), { + status: 200, + headers: { + 'content-type': 'application/json', + 'x-upstream': new URL(request.url).hostname, + }, + }); + }); +} + +describe('provider route parsing', () => { + it('accepts explicit provider paths and strips only the internal prefix', () => { + expect(parseProviderRoute('/providers/featherless/v1/chat/completions')).toEqual({ + providerId: 'featherless', + upstreamPath: '/v1/chat/completions', + }); + expect(parseProviderRoute('/providers/anthropic/v1/messages')).toEqual({ + providerId: 'anthropic', + upstreamPath: '/v1/messages', + }); + }); + + it('does not treat incomplete or malformed paths as explicit provider routes', () => { + expect(parseProviderRoute('/v1/messages')).toBeNull(); + expect(parseProviderRoute('/providers/')).toBeNull(); + expect(parseProviderRoute('/providers/Featherless/v1/chat/completions')).toBeNull(); + expect(parseProviderRoute('/providers/featherless')).toBeNull(); + expect(parseProviderRoute('/providers/featherless//v1/chat/completions')).toBeNull(); + }); + + it('validates provider identifiers', () => { + expect(() => assertProviderId('featherless')).not.toThrow(); + expect(() => assertProviderId('openai-compatible')).not.toThrow(); + expect(() => assertProviderId('Bad_Id')).toThrow(/invalid provider id/); + expect(() => assertProviderId('')).toThrow(/invalid provider id/); + }); +}); + +describe('provider router', () => { + it('routes explicit providers while preserving query, body and response headers', async () => { + const defaultCalls: Array<{ url: string; body: string; authorization: string | null }> = []; + const featherlessCalls: Array<{ url: string; body: string; authorization: string | null }> = []; + const observed: string[] = []; + + const router = createProviderRouter({ + defaultProxy: { + upstream: 'https://legacy.example', + customFetch: echoFetch(defaultCalls), + }, + providers: [{ + id: 'featherless', + protocol: 'openai', + proxy: { + provider: 'featherless', + openAIUpstream: 'https://api.featherless.example', + featherlessTransformMode: 'off', + customFetch: echoFetch(featherlessCalls), + onRequest: (event) => observed.push(`provider:${event.provider}`), + }, + }], + onRequest: (providerId, event) => { + observed.push(`router:${providerId}:${event.provider}`); + }, + }); + + const raw = '{"model":"moonshotai/Kimi-K3","messages":[],"spacing":" preserved "}'; + const response = await router(new Request( + 'http://127.0.0.1:47821/providers/featherless/v1/chat/completions?trace=one', + { + method: 'POST', + headers: { + authorization: 'Bearer incoming-token', + 'content-type': 'application/json', + }, + body: raw, + }, + )); + + expect(response.status).toBe(200); + expect(response.headers.get('x-upstream')).toBe('api.featherless.example'); + expect(featherlessCalls).toHaveLength(1); + expect(featherlessCalls[0]!.url).toBe('https://api.featherless.example/v1/chat/completions?trace=one'); + expect(featherlessCalls[0]!.authorization).toBe('Bearer incoming-token'); + expect(featherlessCalls[0]!.body).toBe(raw); + await response.text(); + + await vi.waitFor(() => { + expect(observed).toEqual([ + 'provider:featherless', + 'router:featherless:featherless', + ]); + }); + }); + + it('keeps legacy unprefixed routes on the default proxy', async () => { + const calls: Array<{ url: string; body: string; authorization: string | null }> = []; + const router = createProviderRouter({ + defaultProxy: { + upstream: 'https://legacy-anthropic.example', + customFetch: echoFetch(calls), + }, + providers: [], + }); + + const response = await router(new Request('http://127.0.0.1:47821/v1/messages', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: 'unsupported-test-model', + max_tokens: 1, + messages: [{ role: 'user', content: 'hello' }], + }), + })); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://legacy-anthropic.example/v1/messages'); + }); + + it('fails unknown explicit providers closed without contacting any upstream', async () => { + const calls: Array<{ url: string; body: string; authorization: string | null }> = []; + const router = createProviderRouter({ + defaultProxy: { + upstream: 'https://legacy.example', + customFetch: echoFetch(calls), + }, + providers: [], + }); + + const response = await router(new Request( + 'http://127.0.0.1:47821/providers/not-configured/v1/messages', + { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }, + )); + expect(response.status).toBe(404); + expect(await response.json()).toEqual({ + error: 'unknown_provider', + provider: 'not-configured', + }); + expect(calls).toHaveLength(0); + }); + + it('does not let query/header/body provider hints change the selected route', async () => { + const defaultCalls: Array<{ url: string; body: string; authorization: string | null }> = []; + const explicitCalls: Array<{ url: string; body: string; authorization: string | null }> = []; + const router = createProviderRouter({ + defaultProxy: { + upstream: 'https://legacy.example', + customFetch: echoFetch(defaultCalls), + }, + providers: [{ + id: 'featherless', + protocol: 'openai', + proxy: { + provider: 'featherless', + openAIUpstream: 'https://api.featherless.example', + featherlessTransformMode: 'off', + customFetch: echoFetch(explicitCalls), + }, + }], + }); + + const response = await router(new Request( + 'http://127.0.0.1:47821/v1/messages?provider=featherless', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'x-pxpipe-provider': 'featherless', + }, + body: JSON.stringify({ provider: 'featherless', messages: [] }), + }, + )); + expect(response.status).toBe(200); + expect(defaultCalls).toHaveLength(1); + expect(explicitCalls).toHaveLength(0); + }); + + it('exposes only credential-free provider metadata', () => { + const router = createProviderRouter({ + defaultProxy: { upstream: 'https://legacy.example', apiKey: 'default-secret' }, + providers: [{ + id: 'featherless', + protocol: 'openai', + proxy: { + provider: 'featherless', + openAIUpstream: 'https://api.featherless.example', + openAIApiKey: 'provider-secret', + }, + }], + }); + expect(router.inspect()).toEqual({ + defaultRoute: 'legacy', + providers: [{ + id: 'featherless', + protocol: 'openai', + prefix: '/providers/featherless', + }], + }); + expect(JSON.stringify(router.inspect())).not.toContain('secret'); + }); + + it('rejects duplicate provider ids', () => { + expect(() => createProviderRouter({ + defaultProxy: { upstream: 'https://legacy.example' }, + providers: [ + { id: 'same', protocol: 'anthropic', proxy: { upstream: 'https://a.example' } }, + { id: 'same', protocol: 'openai', proxy: { openAIUpstream: 'https://b.example' } }, + ], + })).toThrow(/duplicate provider id/); + }); +}); From 5befc910e02a229d65cad71d7ec964a78dc7d9a4 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:15:34 +0100 Subject: [PATCH 03/11] feat(router): export provider registry API --- src/core/index.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/core/index.ts b/src/core/index.ts index ab6fb5a5..b9d3d1ee 100644 --- a/src/core/index.ts +++ b/src/core/index.ts @@ -35,6 +35,16 @@ export { } from './transform.js'; export { transformOpenAIChatCompletions, transformOpenAIResponses, resolveVisionCost, openAIVisionTokens } from './openai.js'; export { createProxy, type ProxyConfig, type ProxyEvent } from './proxy.js'; +export { + createProviderRouter, + parseProviderRoute, + assertProviderId, + type ProviderProtocol, + type ProviderRouteDefinition, + type ProviderRouterConfig, + type ProviderRouterInspection, + type ParsedProviderRoute, +} from './provider-router.js'; export { computeActualInputEff, computeBaselineInputEff, From 1e28f64ede0f9ca7e2ae975d1c3aa7ebccf1c414 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:23:07 +0100 Subject: [PATCH 04/11] fix(router): target current upstream event contract --- src/core/provider-router.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/core/provider-router.ts b/src/core/provider-router.ts index 3a3d8e84..2a58e586 100644 --- a/src/core/provider-router.ts +++ b/src/core/provider-router.ts @@ -74,9 +74,6 @@ function wrapProviderObserver( return { ...definition.proxy, onRequest: async (event) => { - // Keep provider identity explicit even for generic OpenAI-compatible - // providers whose core handler would otherwise leave it unset. - event.provider ??= definition.id; await providerObserver?.(event); await routerObserver?.(definition.id, event); }, From d79748ee78cc0f20cc5b34d8a37091c96d69aef7 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:23:37 +0100 Subject: [PATCH 05/11] test(router): align fixtures with upstream proxy surface --- tests/provider-router.test.ts | 130 ++++++++++++++++------------------ 1 file changed, 60 insertions(+), 70 deletions(-) diff --git a/tests/provider-router.test.ts b/tests/provider-router.test.ts index d72769e8..83b6280e 100644 --- a/tests/provider-router.test.ts +++ b/tests/provider-router.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { assertProviderId, @@ -6,8 +6,14 @@ import { parseProviderRoute, } from '../src/core/provider-router.js'; -function echoFetch(calls: Array<{ url: string; body: string; authorization: string | null }>): typeof fetch { - return vi.fn(async (input, init) => { +interface FetchCall { + url: string; + body: string; + authorization: string | null; +} + +function installEchoFetch(calls: FetchCall[]): void { + vi.stubGlobal('fetch', vi.fn(async (input, init) => { const request = input instanceof Request ? input : new Request(String(input), { @@ -29,13 +35,17 @@ function echoFetch(calls: Array<{ url: string; body: string; authorization: stri 'x-upstream': new URL(request.url).hostname, }, }); - }); + })); } +afterEach(() => { + vi.unstubAllGlobals(); +}); + describe('provider route parsing', () => { it('accepts explicit provider paths and strips only the internal prefix', () => { - expect(parseProviderRoute('/providers/featherless/v1/chat/completions')).toEqual({ - providerId: 'featherless', + expect(parseProviderRoute('/providers/openai-alt/v1/chat/completions')).toEqual({ + providerId: 'openai-alt', upstreamPath: '/v1/chat/completions', }); expect(parseProviderRoute('/providers/anthropic/v1/messages')).toEqual({ @@ -47,13 +57,13 @@ describe('provider route parsing', () => { it('does not treat incomplete or malformed paths as explicit provider routes', () => { expect(parseProviderRoute('/v1/messages')).toBeNull(); expect(parseProviderRoute('/providers/')).toBeNull(); - expect(parseProviderRoute('/providers/Featherless/v1/chat/completions')).toBeNull(); - expect(parseProviderRoute('/providers/featherless')).toBeNull(); - expect(parseProviderRoute('/providers/featherless//v1/chat/completions')).toBeNull(); + expect(parseProviderRoute('/providers/OpenAI/v1/chat/completions')).toBeNull(); + expect(parseProviderRoute('/providers/openai-alt')).toBeNull(); + expect(parseProviderRoute('/providers/openai-alt//v1/chat/completions')).toBeNull(); }); it('validates provider identifiers', () => { - expect(() => assertProviderId('featherless')).not.toThrow(); + expect(() => assertProviderId('anthropic')).not.toThrow(); expect(() => assertProviderId('openai-compatible')).not.toThrow(); expect(() => assertProviderId('Bad_Id')).toThrow(/invalid provider id/); expect(() => assertProviderId('')).toThrow(/invalid provider id/); @@ -61,35 +71,28 @@ describe('provider route parsing', () => { }); describe('provider router', () => { - it('routes explicit providers while preserving query, body and response headers', async () => { - const defaultCalls: Array<{ url: string; body: string; authorization: string | null }> = []; - const featherlessCalls: Array<{ url: string; body: string; authorization: string | null }> = []; + it('routes an explicit OpenAI provider while preserving query, body and auth', async () => { + const calls: FetchCall[] = []; const observed: string[] = []; + installEchoFetch(calls); const router = createProviderRouter({ - defaultProxy: { - upstream: 'https://legacy.example', - customFetch: echoFetch(defaultCalls), - }, + defaultProxy: { upstream: 'https://legacy.example' }, providers: [{ - id: 'featherless', + id: 'openai-alt', protocol: 'openai', proxy: { - provider: 'featherless', - openAIUpstream: 'https://api.featherless.example', - featherlessTransformMode: 'off', - customFetch: echoFetch(featherlessCalls), - onRequest: (event) => observed.push(`provider:${event.provider}`), + openAIUpstream: 'https://api.openai-alt.example', + openAIModels: ['gpt-test'], + onRequest: () => observed.push('provider-observer'), }, }], - onRequest: (providerId, event) => { - observed.push(`router:${providerId}:${event.provider}`); - }, + onRequest: (providerId) => observed.push(`router:${providerId}`), }); - const raw = '{"model":"moonshotai/Kimi-K3","messages":[],"spacing":" preserved "}'; + const raw = '{"model":"gpt-test","messages":[],"spacing":" preserved "}'; const response = await router(new Request( - 'http://127.0.0.1:47821/providers/featherless/v1/chat/completions?trace=one', + 'http://127.0.0.1:47821/providers/openai-alt/v1/chat/completions?trace=one', { method: 'POST', headers: { @@ -101,28 +104,23 @@ describe('provider router', () => { )); expect(response.status).toBe(200); - expect(response.headers.get('x-upstream')).toBe('api.featherless.example'); - expect(featherlessCalls).toHaveLength(1); - expect(featherlessCalls[0]!.url).toBe('https://api.featherless.example/v1/chat/completions?trace=one'); - expect(featherlessCalls[0]!.authorization).toBe('Bearer incoming-token'); - expect(featherlessCalls[0]!.body).toBe(raw); + expect(response.headers.get('x-upstream')).toBe('api.openai-alt.example'); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://api.openai-alt.example/v1/chat/completions?trace=one'); + expect(calls[0]!.authorization).toBe('Bearer incoming-token'); + expect(calls[0]!.body).toBe(raw); await response.text(); await vi.waitFor(() => { - expect(observed).toEqual([ - 'provider:featherless', - 'router:featherless:featherless', - ]); + expect(observed).toEqual(['provider-observer', 'router:openai-alt']); }); }); it('keeps legacy unprefixed routes on the default proxy', async () => { - const calls: Array<{ url: string; body: string; authorization: string | null }> = []; + const calls: FetchCall[] = []; + installEchoFetch(calls); const router = createProviderRouter({ - defaultProxy: { - upstream: 'https://legacy-anthropic.example', - customFetch: echoFetch(calls), - }, + defaultProxy: { upstream: 'https://legacy-anthropic.example' }, providers: [], }); @@ -140,13 +138,11 @@ describe('provider router', () => { expect(calls[0]!.url).toBe('https://legacy-anthropic.example/v1/messages'); }); - it('fails unknown explicit providers closed without contacting any upstream', async () => { - const calls: Array<{ url: string; body: string; authorization: string | null }> = []; + it('fails unknown explicit providers closed without contacting an upstream', async () => { + const calls: FetchCall[] = []; + installEchoFetch(calls); const router = createProviderRouter({ - defaultProxy: { - upstream: 'https://legacy.example', - customFetch: echoFetch(calls), - }, + defaultProxy: { upstream: 'https://legacy.example' }, providers: [], }); @@ -162,51 +158,45 @@ describe('provider router', () => { expect(calls).toHaveLength(0); }); - it('does not let query/header/body provider hints change the selected route', async () => { - const defaultCalls: Array<{ url: string; body: string; authorization: string | null }> = []; - const explicitCalls: Array<{ url: string; body: string; authorization: string | null }> = []; + it('does not let query/header/body hints select an explicit provider', async () => { + const calls: FetchCall[] = []; + installEchoFetch(calls); const router = createProviderRouter({ - defaultProxy: { - upstream: 'https://legacy.example', - customFetch: echoFetch(defaultCalls), - }, + defaultProxy: { upstream: 'https://legacy.example' }, providers: [{ - id: 'featherless', + id: 'openai-alt', protocol: 'openai', proxy: { - provider: 'featherless', - openAIUpstream: 'https://api.featherless.example', - featherlessTransformMode: 'off', - customFetch: echoFetch(explicitCalls), + openAIUpstream: 'https://api.openai-alt.example', + openAIModels: ['gpt-test'], }, }], }); const response = await router(new Request( - 'http://127.0.0.1:47821/v1/messages?provider=featherless', + 'http://127.0.0.1:47821/v1/messages?provider=openai-alt', { method: 'POST', headers: { 'content-type': 'application/json', - 'x-pxpipe-provider': 'featherless', + 'x-pxpipe-provider': 'openai-alt', }, - body: JSON.stringify({ provider: 'featherless', messages: [] }), + body: JSON.stringify({ provider: 'openai-alt', messages: [] }), }, )); expect(response.status).toBe(200); - expect(defaultCalls).toHaveLength(1); - expect(explicitCalls).toHaveLength(0); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://legacy.example/v1/messages'); }); it('exposes only credential-free provider metadata', () => { const router = createProviderRouter({ defaultProxy: { upstream: 'https://legacy.example', apiKey: 'default-secret' }, providers: [{ - id: 'featherless', + id: 'openai-alt', protocol: 'openai', proxy: { - provider: 'featherless', - openAIUpstream: 'https://api.featherless.example', + openAIUpstream: 'https://api.openai-alt.example', openAIApiKey: 'provider-secret', }, }], @@ -214,9 +204,9 @@ describe('provider router', () => { expect(router.inspect()).toEqual({ defaultRoute: 'legacy', providers: [{ - id: 'featherless', + id: 'openai-alt', protocol: 'openai', - prefix: '/providers/featherless', + prefix: '/providers/openai-alt', }], }); expect(JSON.stringify(router.inspect())).not.toContain('secret'); From a629104fef9e189931d78543cd36e40bc0205e7b Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 03:31:33 +0100 Subject: [PATCH 06/11] test(router): preserve query on legacy routes --- tests/provider-router.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/provider-router.test.ts b/tests/provider-router.test.ts index 83b6280e..6183a2a0 100644 --- a/tests/provider-router.test.ts +++ b/tests/provider-router.test.ts @@ -186,7 +186,7 @@ describe('provider router', () => { )); expect(response.status).toBe(200); expect(calls).toHaveLength(1); - expect(calls[0]!.url).toBe('https://legacy.example/v1/messages'); + expect(calls[0]!.url).toBe('https://legacy.example/v1/messages?provider=openai-alt'); }); it('exposes only credential-free provider metadata', () => { From 8822d290b32c38f77e68d254646b8a934c1691dc Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:47:25 +0100 Subject: [PATCH 07/11] fix(router): fail malformed provider namespace closed --- src/core/provider-router.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/src/core/provider-router.ts b/src/core/provider-router.ts index 2a58e586..74e50f5c 100644 --- a/src/core/provider-router.ts +++ b/src/core/provider-router.ts @@ -100,6 +100,16 @@ function rewriteProviderRequest(request: Request, route: ParsedProviderRoute): R return new Request(sourceUrl, init); } +function invalidProviderRoute(): Response { + return new Response( + JSON.stringify({ error: 'invalid_provider_route' }), + { + status: 400, + headers: { 'content-type': 'application/json' }, + }, + ); +} + /** * Create one request handler that multiplexes several provider-specific * `createProxy` instances behind one Web-standard request handler. @@ -128,8 +138,11 @@ export function createProviderRouter( } const route = async (request: Request): Promise => { - const parsed = parseProviderRoute(new URL(request.url).pathname); - if (!parsed) return defaultHandler(request); + const pathname = new URL(request.url).pathname; + const parsed = parseProviderRoute(pathname); + // `/providers/` is a reserved internal namespace. Malformed explicit routes + // must never fall through to the legacy/default upstream. + if (!parsed) return pathname.startsWith(PREFIX) ? invalidProviderRoute() : defaultHandler(request); const handler = handlers.get(parsed.providerId); if (!handler) { From c236a8357d8db167efd899edbf4eb47aa7b5e247 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:47:54 +0100 Subject: [PATCH 08/11] test(router): cover malformed reserved provider routes --- tests/provider-router.test.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/provider-router.test.ts b/tests/provider-router.test.ts index 6183a2a0..e6f792de 100644 --- a/tests/provider-router.test.ts +++ b/tests/provider-router.test.ts @@ -54,7 +54,7 @@ describe('provider route parsing', () => { }); }); - it('does not treat incomplete or malformed paths as explicit provider routes', () => { + it('does not treat incomplete or malformed paths as valid provider routes', () => { expect(parseProviderRoute('/v1/messages')).toBeNull(); expect(parseProviderRoute('/providers/')).toBeNull(); expect(parseProviderRoute('/providers/OpenAI/v1/chat/completions')).toBeNull(); @@ -158,6 +158,33 @@ describe('provider router', () => { expect(calls).toHaveLength(0); }); + it.each([ + '/providers/', + '/providers/OpenAI/v1/chat/completions', + '/providers/openai-alt', + '/providers/openai-alt//v1/chat/completions', + ])('fails malformed reserved provider route %s closed', async (pathname) => { + const calls: FetchCall[] = []; + installEchoFetch(calls); + const router = createProviderRouter({ + defaultProxy: { upstream: 'https://legacy.example' }, + providers: [{ + id: 'openai-alt', + protocol: 'openai', + proxy: { openAIUpstream: 'https://api.openai-alt.example' }, + }], + }); + + const response = await router(new Request(`http://127.0.0.1:47821${pathname}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{}', + })); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: 'invalid_provider_route' }); + expect(calls).toHaveLength(0); + }); + it('does not let query/header/body hints select an explicit provider', async () => { const calls: FetchCall[] = []; installEchoFetch(calls); From 8a5771aab6a4678fd163c5bca1543cf5ab8e58e6 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:51:26 +0100 Subject: [PATCH 09/11] fix(router): reserve provider namespace root --- src/core/provider-router.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/core/provider-router.ts b/src/core/provider-router.ts index 74e50f5c..ef08c63c 100644 --- a/src/core/provider-router.ts +++ b/src/core/provider-router.ts @@ -35,7 +35,8 @@ export interface ProviderRouterInspection { } const PROVIDER_ID = /^[a-z][a-z0-9-]{0,62}$/; -const PREFIX = '/providers/'; +const PROVIDER_ROOT = '/providers'; +const PREFIX = `${PROVIDER_ROOT}/`; export function assertProviderId(id: string): void { if (!PROVIDER_ID.test(id)) { @@ -66,6 +67,10 @@ export function parseProviderRoute(pathname: string): ParsedProviderRoute | null return { providerId, upstreamPath }; } +function isProviderNamespace(pathname: string): boolean { + return pathname === PROVIDER_ROOT || pathname.startsWith(PREFIX); +} + function wrapProviderObserver( definition: ProviderRouteDefinition, routerObserver: ProviderRouterConfig['onRequest'], @@ -140,9 +145,9 @@ export function createProviderRouter( const route = async (request: Request): Promise => { const pathname = new URL(request.url).pathname; const parsed = parseProviderRoute(pathname); - // `/providers/` is a reserved internal namespace. Malformed explicit routes + // `/providers` is a reserved internal namespace. Malformed explicit routes // must never fall through to the legacy/default upstream. - if (!parsed) return pathname.startsWith(PREFIX) ? invalidProviderRoute() : defaultHandler(request); + if (!parsed) return isProviderNamespace(pathname) ? invalidProviderRoute() : defaultHandler(request); const handler = handlers.get(parsed.providerId); if (!handler) { From 0d239f1db2fe79b4f506b38d03bf58360b15e580 Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 04:52:01 +0100 Subject: [PATCH 10/11] test(router): cover provider namespace root --- tests/provider-router.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/provider-router.test.ts b/tests/provider-router.test.ts index e6f792de..b000f914 100644 --- a/tests/provider-router.test.ts +++ b/tests/provider-router.test.ts @@ -56,6 +56,7 @@ describe('provider route parsing', () => { it('does not treat incomplete or malformed paths as valid provider routes', () => { expect(parseProviderRoute('/v1/messages')).toBeNull(); + expect(parseProviderRoute('/providers')).toBeNull(); expect(parseProviderRoute('/providers/')).toBeNull(); expect(parseProviderRoute('/providers/OpenAI/v1/chat/completions')).toBeNull(); expect(parseProviderRoute('/providers/openai-alt')).toBeNull(); @@ -159,6 +160,7 @@ describe('provider router', () => { }); it.each([ + '/providers', '/providers/', '/providers/OpenAI/v1/chat/completions', '/providers/openai-alt', From 26f19fc45c6b06b6ecba5e49353d018152ec428e Mon Sep 17 00:00:00 2001 From: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com> Date: Wed, 12 Aug 2026 05:01:58 +0100 Subject: [PATCH 11/11] feat(codex): add first-class native routing --- README.md | 25 +++- bin/cli.js | 6 +- docs/CODEX_INTEGRATION.md | 45 ++++++ package.json | 1 + src/codex-entry.ts | 87 +++++++++++ src/core/codex-model.ts | 155 +++++++++++++++++++ src/core/codex.ts | 185 +++++++++++++++++++++++ src/node.ts | 18 ++- tests/codex-native-routing.test.ts | 230 +++++++++++++++++++++++++++++ 9 files changed, 747 insertions(+), 5 deletions(-) create mode 100644 docs/CODEX_INTEGRATION.md create mode 100644 src/codex-entry.ts create mode 100644 src/core/codex-model.ts create mode 100644 src/core/codex.ts create mode 100644 tests/codex-native-routing.test.ts diff --git a/README.md b/README.md index c1aad933..820067d9 100644 --- a/README.md +++ b/README.md @@ -57,16 +57,39 @@ normally — pxpipe compresses the *request* only, never the model's output. Recent turns stay text; the system prompt, tool docs, and older bulk history are imaged. +### `pxpipe codex` + +Codex has a dedicated native Responses integration. Start the persistent PXPipe +listener normally, then launch Codex through it: + +```bash +pxpipe codex +pxpipe codex --binary codex-ar +``` + +The launcher preserves Codex-owned ChatGPT authentication and `CODEX_HOME`, +routes `/backend-api/codex/responses` through PXPipe, and leaves native +`/responses/compact` traffic untouched. If the listener is unavailable it +falls back to a direct Codex launch without rewriting the caller environment. + +Use `pxpipe codex --direct` to request the direct path explicitly. + +See [docs/CODEX_INTEGRATION.md](docs/CODEX_INTEGRATION.md) for the routing and +authentication contract. + ### `pxpipe warp` ```bash -pxpipe warp -- claude # also: cursor-agent, codex, or a shell alias +pxpipe warp -- claude # also: cursor-agent or a shell alias ``` Same thing without `ANTHROPIC_BASE_URL`, so `/remote-control`, claude.ai connectors, and first-party gates keep working. Full instructions in the dashboard. +For Codex, prefer the dedicated `pxpipe codex` launcher above rather than the +Anthropic-oriented Warp path. + `api.anthropic.com/v1/messages` is routed by default. Agents that reach their provider over some other base URL need a rule for it, and a rule that names a port matches only that port: diff --git a/bin/cli.js b/bin/cli.js index 04e4cef6..9ba352be 100755 --- a/bin/cli.js +++ b/bin/cli.js @@ -1,6 +1,8 @@ #!/usr/bin/env node -// Tiny shim: just runs the bundled Node entry. Real CLI logic lives in src/node.ts. -import('../dist/node.js').catch((err) => { +// Tiny shim: dispatch the dedicated Codex launcher before the bundled server +// entry. All other CLI logic remains in src/node.ts. +const entry = process.argv[2] === 'codex' ? '../dist/codex-entry.js' : '../dist/node.js'; +import(entry).catch((err) => { console.error('[pxpipe] failed to start:', err); console.error('[pxpipe] did you forget to `npm run build`?'); process.exit(1); diff --git a/docs/CODEX_INTEGRATION.md b/docs/CODEX_INTEGRATION.md new file mode 100644 index 00000000..3a8c2817 --- /dev/null +++ b/docs/CODEX_INTEGRATION.md @@ -0,0 +1,45 @@ +# Codex integration + +PXPipe can launch Codex through the existing persistent loopback listener without +rewriting Codex authentication files or replacing the native Responses API. + +```bash +pxpipe codex +pxpipe codex --binary codex-ar +``` + +The launcher installs temporary Codex provider overrides for the child process: + +- provider id: `pxpipe`; +- provider display name: `OpenAI`; +- wire API: `responses`; +- auth: Codex/OpenAI auth remains owned by the caller; +- base URL: `http://127.0.0.1:/providers/codex/backend-api/codex`. + +`CODEX_HOME` is not changed, so alternate wrappers/accounts keep their own +configuration and authentication state. While routing through PXPipe, +`OPENAI_BASE_URL` and inherited loopback proxy variables are removed from the +child because provider routing is expressed through Codex's model-provider +configuration instead. Explicit `--direct` mode and the automatic fallback used +when no persistent listener is available preserve the caller environment +unchanged. + +The persistent Node listener owns an isolated `codex` provider route whose +Anthropic/default and OpenAI upstream bases both point to `https://chatgpt.com`. +The route does not inherit alternate gateway routing, gateway headers, API keys, +or Cloudflare provider credentials from the default listener. That lets normal +`/backend-api/codex/responses` requests use PXPipe's existing Responses +transform/accounting path while native endpoints such as `/responses/compact` +remain pass-through requests on the same authenticated origin. + +PXPipe does not claim WebSocket Responses support here; the provider override +sets `supports_websockets=false`. Use the dedicated `pxpipe codex` launcher, +not the Anthropic-oriented Warp path. + +If the persistent listener is unavailable, the launcher prints a warning and +starts Codex directly. `--direct` requests that behavior explicitly. + +Model selection is read-only. Explicit Codex model arguments win, then the +selected profile model, then top-level `config.toml`. When no persistent model +can be resolved, PXPipe leaves model selection to the installed Codex CLI +instead of injecting a reference model. diff --git a/package.json b/package.json index ee596733..ad1c9ec9 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "assets/*LICENSE.txt", "README.md", "SECURITY.md", + "docs/CODEX_INTEGRATION.md", "docs/SECURITY_MODEL.md", "LICENSE" ], diff --git a/src/codex-entry.ts b/src/codex-entry.ts new file mode 100644 index 00000000..0995c80b --- /dev/null +++ b/src/codex-entry.ts @@ -0,0 +1,87 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { readFileSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { + buildCodexCommandArgs, + buildCodexEnvironment, + parseCodexInvocation, + resolveCodexPersistentProxy, +} from './core/codex.js'; +import { resolveCodexModelSelection } from './core/codex-model.js'; + +const REFERENCE_MODEL = 'gpt-5.6-sol'; + +function readCodexConfig(env: NodeJS.ProcessEnv): string | undefined { + const root = env.CODEX_HOME?.trim() || join(homedir(), '.codex'); + try { return readFileSync(join(root, 'config.toml'), 'utf8'); } + catch { return undefined; } +} + +function launch(binary: string, args: string[], env: NodeJS.ProcessEnv): void { + const child = spawn(binary, args, { stdio: 'inherit', env }); + const handlers = new Map void>(); + for (const signal of ['SIGINT', 'SIGTERM', 'SIGHUP', 'SIGQUIT'] as const) { + const handler = (): void => { child.kill(signal); }; + handlers.set(signal, handler); + process.on(signal, handler); + } + const cleanup = (): void => { + for (const [signal, handler] of handlers) process.off(signal, handler); + }; + child.on('error', (error) => { + cleanup(); + console.error(`[pxpipe] codex: cannot run ${binary}: ${error.message}`); + process.exitCode = 127; + }); + child.on('exit', (code, signal) => { + cleanup(); + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exitCode = code ?? 0; + }); +} + +async function main(): Promise { + let invocation; + try { + invocation = parseCodexInvocation(process.argv.slice(2)); + } catch (error) { + console.error(`[pxpipe] codex: ${(error as Error).message}`); + process.exitCode = 2; + return; + } + + const directEnv = buildCodexEnvironment(process.env, 'direct'); + if (invocation.direct) { + launch(invocation.binary, invocation.args, directEnv); + return; + } + + const proxy = await resolveCodexPersistentProxy(process.env); + if (!proxy) { + console.warn('[pxpipe] codex: persistent listener unavailable; launching Codex direct'); + launch(invocation.binary, invocation.args, directEnv); + return; + } + + const childEnv = buildCodexEnvironment(process.env, 'proxied'); + + const selection = resolveCodexModelSelection( + invocation.args, + readCodexConfig(process.env), + REFERENCE_MODEL, + ); + // Do not inject the diagnostic reference fallback into Codex. If no user + // model can be resolved, leave model selection to the installed Codex CLI. + const resolvedModel = selection.source === 'reference' ? undefined : selection.model; + const args = buildCodexCommandArgs(proxy.baseUrl, invocation.args, resolvedModel); + console.error(`[pxpipe] codex → 127.0.0.1:${proxy.port} (native Responses route)`); + launch(invocation.binary, args, childEnv); +} + +void main(); diff --git a/src/core/codex-model.ts b/src/core/codex-model.ts new file mode 100644 index 00000000..0fa7af6e --- /dev/null +++ b/src/core/codex-model.ts @@ -0,0 +1,155 @@ +/** Narrow, read-only Codex model-selection helpers. */ + +export type CodexModelSource = 'cli' | 'profile' | 'config' | 'reference'; + +export interface CodexModelSelection { + model: string; + source: CodexModelSource; + profile?: string; +} + +interface ParsedCodexConfig { + model?: string; + profile?: string; + profileModels: Map; +} + +function stripTomlComment(raw: string): string { + let quote: 'single' | 'double' | null = null; + let escaped = false; + for (let i = 0; i < raw.length; i += 1) { + const ch = raw[i]!; + if (quote === 'double') { + if (escaped) { escaped = false; continue; } + if (ch === '\\') { escaped = true; continue; } + if (ch === '"') quote = null; + continue; + } + if (quote === 'single') { + if (ch === "'") quote = null; + continue; + } + if (ch === '"') quote = 'double'; + else if (ch === "'") quote = 'single'; + else if (ch === '#') return raw.slice(0, i); + } + return raw; +} + +function unquoteTomlScalar(raw: string): string | undefined { + const value = stripTomlComment(raw).trim(); + if (!value) return undefined; + if (value.startsWith('"')) { + try { + const parsed = JSON.parse(value) as unknown; + return typeof parsed === 'string' && parsed.trim() ? parsed.trim() : undefined; + } catch { return undefined; } + } + if (value.startsWith("'")) { + const end = value.indexOf("'", 1); + if (end < 0) return undefined; + return value.slice(1, end).trim() || undefined; + } + return value.split(/\s+/)[0]?.trim() || undefined; +} + +function assignmentValue(raw: string, key: string): string | undefined { + const eq = raw.indexOf('='); + if (eq < 0 || raw.slice(0, eq).trim() !== key) return undefined; + return unquoteTomlScalar(raw.slice(eq + 1)); +} + +function configOverride(args: readonly string[], key: string): string | undefined { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '-c' || arg === '--config') { + const next = args[i + 1]; + if (next !== undefined) { + const value = assignmentValue(next, key); + if (value !== undefined) return value; + i += 1; + } + } else if (arg.startsWith('-c=')) { + const value = assignmentValue(arg.slice(3), key); + if (value !== undefined) return value; + } else if (arg.startsWith('--config=')) { + const value = assignmentValue(arg.slice('--config='.length), key); + if (value !== undefined) return value; + } + } + return undefined; +} + +export function codexModelFromArgs(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '-m' || arg === '--model') { + const next = args[i + 1]?.trim(); + if (next) return next; + } else if (arg.startsWith('--model=')) { + const value = arg.slice('--model='.length).trim(); + if (value) return value; + } + } + return configOverride(args, 'model'); +} + +export function codexProfileFromArgs(args: readonly string[]): string | undefined { + for (let i = 0; i < args.length; i += 1) { + const arg = args[i]!; + if (arg === '-p' || arg === '--profile') { + const next = args[i + 1]?.trim(); + if (next) return next; + } else if (arg.startsWith('--profile=')) { + const value = arg.slice('--profile='.length).trim(); + if (value) return value; + } + } + return configOverride(args, 'profile'); +} + +function parseCodexConfig(text: string | undefined): ParsedCodexConfig { + const parsed: ParsedCodexConfig = { profileModels: new Map() }; + if (!text) return parsed; + let profileSection: string | null = null; + let inOtherSection = false; + for (const rawLine of text.split(/\r?\n/)) { + const line = stripTomlComment(rawLine).trim(); + if (!line) continue; + const section = /^\[([^\]]+)\]$/.exec(line); + if (section) { + const profile = /^profiles\.([A-Za-z0-9_.-]+)$/.exec(section[1]!.trim()); + profileSection = profile?.[1] ?? null; + inOtherSection = profileSection === null; + continue; + } + const model = assignmentValue(line, 'model'); + if (model !== undefined) { + if (profileSection !== null) parsed.profileModels.set(profileSection, model); + else if (!inOtherSection) parsed.model = model; + continue; + } + if (!inOtherSection && profileSection === null) { + const profile = assignmentValue(line, 'profile'); + if (profile !== undefined) parsed.profile = profile; + } + } + return parsed; +} + +export function resolveCodexModelSelection( + args: readonly string[], + configText: string | undefined, + referenceModel: string, +): CodexModelSelection { + const explicit = codexModelFromArgs(args); + if (explicit) return { model: explicit, source: 'cli' }; + const config = parseCodexConfig(configText); + const profile = codexProfileFromArgs(args) ?? config.profile; + if (profile) { + const model = config.profileModels.get(profile); + if (model) return { model, source: 'profile', profile }; + } + if (config.model) return { model: config.model, source: 'config' }; + return { model: referenceModel, source: 'reference', ...(profile ? { profile } : {}) }; +} diff --git a/src/core/codex.ts b/src/core/codex.ts new file mode 100644 index 00000000..a342c8b7 --- /dev/null +++ b/src/core/codex.ts @@ -0,0 +1,185 @@ +/** First-class Codex CLI routing through the persistent PXPipe listener. */ + +import type { ProxyConfig } from './proxy.js'; + +export const CODEX_PROVIDER_ID = 'codex'; +export const CODEX_MODEL_PROVIDER_ID = 'pxpipe'; +export const CODEX_NATIVE_PROVIDER_NAME = 'OpenAI'; +export const DEFAULT_CODEX_PORT = 47821; +export const DEFAULT_CODEX_CHATGPT_BASE = 'https://chatgpt.com'; + +/** + * Derive the dedicated Codex route from the main listener configuration. + * + * Operational settings such as transform policy, observers, size limits and + * timeouts are inherited. Provider/gateway routing and credentials are not: + * Codex must always forward the caller's own ChatGPT authentication directly + * to the ChatGPT Codex origin. + */ +export function buildCodexProxyConfig(base: ProxyConfig): ProxyConfig { + return { + ...base, + + // Never inherit an alternate provider/gateway from the default listener. + provider: undefined, + gatewayBaseUrl: undefined, + gatewayHeaders: undefined, + + upstream: DEFAULT_CODEX_CHATGPT_BASE, + apiKey: undefined, + authToken: undefined, + + openAIUpstream: DEFAULT_CODEX_CHATGPT_BASE, + openAIApiKey: undefined, + + // A provider-specific Cloudflare route or credential must not bleed into + // the ChatGPT OAuth path either. + cloudflareUpstream: undefined, + cloudflareApiKey: undefined, + + openAIModels: [], + cloudflareModels: [], + }; +} + +/** + * Codex appends `/responses`, `/responses/compact`, and `/models` to this base. + * The provider router removes `/providers/codex`; the remaining ChatGPT path is + * forwarded by the isolated Codex proxy configuration. + */ +export function codexProviderBaseUrl(port: number): string { + return `http://127.0.0.1:${port}/providers/${CODEX_PROVIDER_ID}/backend-api/codex`; +} + +/** Temporary Codex config overrides. No Codex files are modified. */ +export function buildCodexConfigArgs(baseUrl: string): string[] { + const provider = `model_providers.${CODEX_MODEL_PROVIDER_ID}`; + return [ + '-c', `${provider}.name=${CODEX_NATIVE_PROVIDER_NAME}`, + '-c', `${provider}.base_url=${baseUrl}`, + '-c', `${provider}.wire_api=responses`, + '-c', `${provider}.requires_openai_auth=true`, + '-c', `${provider}.supports_websockets=false`, + '-c', `model_provider=${CODEX_MODEL_PROVIDER_ID}`, + ]; +} + +function isLoopbackProxyUrl(value: string | undefined): boolean { + if (!value) return false; + try { + const { hostname } = new URL(value.includes('://') ? value : `http://${value}`); + return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '::1'; + } catch { + return false; + } +} + +function appendNoProxy(existing: string | undefined): string { + const wanted = ['127.0.0.1', 'localhost', '::1']; + const have = (existing ?? '').split(',').map((item) => item.trim()).filter(Boolean); + for (const entry of wanted) if (!have.includes(entry)) have.push(entry); + return have.join(','); +} + +/** + * Build a child environment without disturbing CODEX_HOME/auth state. + * + * Endpoint overrides from other wrappers are removed because this launcher uses + * Codex's model-provider config layer. Loopback Warp proxies are removed so the + * plain HTTP hop to the persistent listener cannot be intercepted recursively. + */ +export type CodexEnvironmentMode = 'proxied' | 'direct'; + +export function buildCodexEnvironment( + source: NodeJS.ProcessEnv, + mode: CodexEnvironmentMode = 'proxied', +): NodeJS.ProcessEnv { + const env = { ...source }; + + // A direct launch must be observationally equivalent to invoking Codex + // without PXPipe: preserve caller endpoint/proxy configuration untouched. + if (mode === 'direct') return env; + + delete env.OPENAI_BASE_URL; + for (const key of ['HTTP_PROXY', 'http_proxy', 'HTTPS_PROXY', 'https_proxy'] as const) { + if (isLoopbackProxyUrl(env[key])) delete env[key]; + } + env.NO_PROXY = appendNoProxy(env.NO_PROXY); + env.no_proxy = appendNoProxy(env.no_proxy); + return env; +} + +export interface CodexInvocation { + binary: string; + direct: boolean; + args: string[]; +} + +/** `pxpipe codex [--binary NAME] [--direct] [--] [codex args...]` */ +export function parseCodexInvocation( + argv: readonly string[], + env: NodeJS.ProcessEnv = process.env, +): CodexInvocation { + const rest = argv[0] === 'codex' ? argv.slice(1) : [...argv]; + let binary = env.PXPIPE_CODEX_BINARY?.trim() || 'codex'; + let direct = false; + let index = 0; + for (; index < rest.length; index += 1) { + const arg = rest[index]!; + if (arg === '--') { + index += 1; + break; + } + if (arg === '--binary') { + const value = rest[index + 1]; + if (value === undefined || value.startsWith('-')) { + throw new Error('--binary requires an executable name or path'); + } + binary = value; + index += 1; + continue; + } + if (arg.startsWith('--binary=')) { + binary = arg.slice('--binary='.length); + if (!binary) throw new Error('--binary requires an executable name or path'); + continue; + } + if (arg === '--direct') { + direct = true; + continue; + } + break; + } + return { binary, direct, args: rest.slice(index) }; +} + +export function buildCodexCommandArgs( + baseUrl: string, + args: readonly string[], + resolvedModel?: string, +): string[] { + const modelArgs = resolvedModel?.trim() ? ['-c', `model=${resolvedModel.trim()}`] : []; + return [...buildCodexConfigArgs(baseUrl), ...modelArgs, ...args]; +} + +export function resolveCodexPort(env: NodeJS.ProcessEnv = process.env): number { + const raw = Number(env.PORT ?? DEFAULT_CODEX_PORT); + return Number.isSafeInteger(raw) && raw > 0 && raw <= 65535 ? raw : DEFAULT_CODEX_PORT; +} + +/** Health-check the already-running PXPipe listener; never binds another port. */ +export async function resolveCodexPersistentProxy( + env: NodeJS.ProcessEnv = process.env, + fetchFn: typeof fetch = fetch, +): Promise<{ baseUrl: string; port: number } | null> { + const port = resolveCodexPort(env); + try { + const response = await fetchFn(`http://127.0.0.1:${port}/proxy-stats`, { + signal: AbortSignal.timeout(1_000), + }); + if (!response.ok) return null; + } catch { + return null; + } + return { baseUrl: codexProviderBaseUrl(port), port }; +} diff --git a/src/node.ts b/src/node.ts index 9354e453..3f4b6c7a 100644 --- a/src/node.ts +++ b/src/node.ts @@ -14,7 +14,9 @@ import * as path from 'node:path'; import * as os from 'node:os'; import { isIP } from 'node:net'; import { spawnSync } from 'node:child_process'; -import { createProxy, parseGatewayHeaders, resolveUpstreams, type ProxyConfig } from './core/proxy.js'; +import { parseGatewayHeaders, resolveUpstreams, type ProxyConfig } from './core/proxy.js'; +import { createProviderRouter } from './core/provider-router.js'; +import { buildCodexProxyConfig } from './core/codex.js'; import { chatCompletionsUrl, } from './core/messages-chat-bridge.js'; @@ -1329,7 +1331,19 @@ async function main(): Promise { tracker.emit(toTrackEvent(e)); }, }; - const handle = createProxy(config); + // Codex uses ChatGPT OAuth plus the Responses API. Give it an isolated route + // so ordinary OpenAI/API-key traffic keeps its configured upstream while the + // caller's own ChatGPT bearer remains untouched. Both bases point at + // chatgpt.com: `/backend-api/codex/responses` is transformed by core, while + // native `/responses/compact` and `/models` pass through byte-for-byte. + const handle = createProviderRouter({ + defaultProxy: config, + providers: [{ + id: 'codex', + protocol: 'openai', + proxy: buildCodexProxyConfig(config), + }], + }); const server = createServer((req, res) => { Promise.resolve() diff --git a/tests/codex-native-routing.test.ts b/tests/codex-native-routing.test.ts new file mode 100644 index 00000000..84eaa6c0 --- /dev/null +++ b/tests/codex-native-routing.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { + buildCodexConfigArgs, + buildCodexEnvironment, + buildCodexProxyConfig, + codexProviderBaseUrl, + parseCodexInvocation, + resolveCodexPersistentProxy, +} from '../src/core/codex.js'; +import { resolveCodexModelSelection } from '../src/core/codex-model.js'; +import { createProviderRouter } from '../src/core/provider-router.js'; + +interface Call { + url: string; + auth: string | null; + gatewayAuth: string | null; + body: string; +} + +function installFetch(calls: Call[], responseBody = '{}'): void { + vi.stubGlobal('fetch', vi.fn(async (input, init) => { + const request = input instanceof Request + ? input + : new Request(String(input), { ...init, ...(init?.body ? { duplex: 'half' as const } : {}) }); + calls.push({ + url: request.url, + auth: request.headers.get('authorization'), + gatewayAuth: request.headers.get('cf-aig-authorization'), + body: request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text(), + }); + return new Response(responseBody, { status: 200, headers: { 'content-type': 'application/json' } }); + })); +} + +afterEach(() => vi.unstubAllGlobals()); + +describe('Codex launcher contract', () => { + it('uses the persistent provider route and native OpenAI provider identity', () => { + const base = codexProviderBaseUrl(47821); + expect(base).toBe('http://127.0.0.1:47821/providers/codex/backend-api/codex'); + expect(buildCodexConfigArgs(base)).toEqual([ + '-c', 'model_providers.pxpipe.name=OpenAI', + '-c', `model_providers.pxpipe.base_url=${base}`, + '-c', 'model_providers.pxpipe.wire_api=responses', + '-c', 'model_providers.pxpipe.requires_openai_auth=true', + '-c', 'model_providers.pxpipe.supports_websockets=false', + '-c', 'model_provider=pxpipe', + ]); + }); + + it('supports alternate Codex binaries without changing their home/auth state', () => { + expect(parseCodexInvocation(['codex', '--binary', 'codex-ar', '--', 'exec', 'hello'])).toEqual({ + binary: 'codex-ar', + direct: false, + args: ['exec', 'hello'], + }); + const env = buildCodexEnvironment({ + CODEX_HOME: '/tmp/codex-alt', + OPENAI_BASE_URL: 'https://stale.example', + HTTPS_PROXY: 'http://127.0.0.1:9999', + NO_PROXY: 'example.test', + }); + expect(env.CODEX_HOME).toBe('/tmp/codex-alt'); + expect(env.OPENAI_BASE_URL).toBeUndefined(); + expect(env.HTTPS_PROXY).toBeUndefined(); + expect(env.NO_PROXY).toContain('127.0.0.1'); + expect(env.NO_PROXY).toContain('localhost'); + }); + + it('preserves the caller environment for direct launches', () => { + const source = { + CODEX_HOME: '/tmp/codex-alt', + OPENAI_BASE_URL: 'https://custom-openai.example/v1', + HTTPS_PROXY: 'http://127.0.0.1:9999', + NO_PROXY: 'example.test', + }; + + const env = buildCodexEnvironment(source, 'direct'); + + expect(env).toEqual(source); + expect(env).not.toBe(source); + }); + + it('resolves explicit model, profile model, then top-level config', () => { + const config = ` +model = "gpt-config" +profile = "work" + +[profiles.work] +model = "gpt-profile" +`; + expect(resolveCodexModelSelection(['--model', 'gpt-cli'], config, 'gpt-ref')).toEqual({ + model: 'gpt-cli', source: 'cli', + }); + expect(resolveCodexModelSelection([], config, 'gpt-ref')).toEqual({ + model: 'gpt-profile', source: 'profile', profile: 'work', + }); + expect(resolveCodexModelSelection([], 'model = "gpt-config"', 'gpt-ref')).toEqual({ + model: 'gpt-config', source: 'config', + }); + }); + + it('health-checks the existing listener without starting another one', async () => { + const calls: Call[] = []; + const fetchFn = vi.fn(async (input) => { + calls.push({ + url: String(input), + auth: null, + gatewayAuth: null, + body: '', + }); + return new Response('{}', { status: 200 }); + }); + await expect(resolveCodexPersistentProxy({ PORT: '47821' }, fetchFn)).resolves.toEqual({ + baseUrl: codexProviderBaseUrl(47821), + port: 47821, + }); + expect(calls[0]!.url).toBe('http://127.0.0.1:47821/proxy-stats'); + }); +}); + +describe('Codex provider route', () => { + function router() { + return createProviderRouter({ + defaultProxy: { upstream: 'https://api.anthropic.example' }, + providers: [{ + id: 'codex', + protocol: 'openai', + proxy: { + upstream: 'https://chatgpt.com', + openAIUpstream: 'https://chatgpt.com', + openAIModels: [], + cloudflareModels: [], + }, + }], + }); + } + + it('forwards ChatGPT Responses to the exact native endpoint and preserves caller OAuth', async () => { + const calls: Call[] = []; + installFetch(calls, '{"id":"resp_test"}'); + const jwt = 'Bearer eyJhbGciOiJub25lIn0.abc.def'; + const response = await router()(new Request( + 'http://127.0.0.1:47821/providers/codex/backend-api/codex/responses', + { + method: 'POST', + headers: { authorization: jwt, 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-unconfigured-test', input: 'hello', stream: false }), + }, + )); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(calls[0]!.auth).toBe(jwt); + }); + + it('does not inherit gateway routing or credentials into the Codex route', async () => { + const calls: Call[] = []; + installFetch(calls); + + const isolated = buildCodexProxyConfig({ + provider: 'cloudflare-ai-gateway', + gatewayBaseUrl: 'https://gateway.example', + gatewayHeaders: { + 'cf-aig-authorization': 'Bearer gateway-secret', + }, + upstream: 'https://legacy-anthropic.example', + openAIUpstream: 'https://legacy-openai.example', + cloudflareUpstream: 'https://legacy-cloudflare.example', + cloudflareApiKey: 'cloudflare-secret', + }); + + const gatewayRouter = createProviderRouter({ + defaultProxy: { + upstream: 'https://legacy-anthropic.example', + }, + providers: [{ + id: 'codex', + protocol: 'openai', + proxy: isolated, + }], + }); + + const oauth = 'Bearer chatgpt-oauth-token'; + + const response = await gatewayRouter(new Request( + 'http://127.0.0.1:47821/providers/codex/backend-api/codex/responses', + { + method: 'POST', + headers: { + authorization: oauth, + 'content-type': 'application/json', + }, + body: JSON.stringify({ + model: 'gpt-unconfigured-test', + input: 'hello', + stream: false, + }), + }, + )); + + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]!.url) + .toBe('https://chatgpt.com/backend-api/codex/responses'); + expect(calls[0]!.auth).toBe(oauth); + expect(calls[0]!.gatewayAuth).toBeNull(); + }); + + it('forwards native compact byte-for-byte on the same authenticated route', async () => { + const calls: Call[] = []; + installFetch(calls); + const body = '{"model":"gpt-test","input":[{"type":"opaque","value":" exact "}]}'; + const jwt = 'Bearer eyJhbGciOiJub25lIn0.abc.def'; + const response = await router()(new Request( + 'http://127.0.0.1:47821/providers/codex/backend-api/codex/responses/compact', + { + method: 'POST', + headers: { authorization: jwt, 'content-type': 'application/json' }, + body, + }, + )); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe('https://chatgpt.com/backend-api/codex/responses/compact'); + expect(calls[0]!.auth).toBe(jwt); + expect(calls[0]!.body).toBe(body); + }); +});