[daily] route codex websocket through site api endpoints (#386)
* fix: route codex websocket through site api endpoints * fix: preserve websocket endpoint exhaustion errors * test: align proxy route mocks with first-byte timeout helpers
This commit is contained in:
@@ -384,6 +384,8 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
modelActual: 'upstream-model',
|
modelActual: 'upstream-model',
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
httpStatus: 502,
|
httpStatus: 502,
|
||||||
|
isStream: null,
|
||||||
|
firstByteLatencyMs: null,
|
||||||
latencyMs: 1200,
|
latencyMs: 1200,
|
||||||
promptTokens: 10,
|
promptTokens: 10,
|
||||||
completionTokens: 5,
|
completionTokens: 5,
|
||||||
@@ -995,6 +997,8 @@ describe('selectSurfaceChannelForAttempt', () => {
|
|||||||
modelRequested: 'gpt-5.2',
|
modelRequested: 'gpt-5.2',
|
||||||
status: 'success',
|
status: 'success',
|
||||||
httpStatus: 200,
|
httpStatus: 200,
|
||||||
|
isStream: null,
|
||||||
|
firstByteLatencyMs: null,
|
||||||
latencyMs: 250,
|
latencyMs: 250,
|
||||||
errorMessage: null,
|
errorMessage: null,
|
||||||
retryCount: 1,
|
retryCount: 1,
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ vi.mock('../../db/index.js', () => {
|
|||||||
insert: () => insertChain,
|
insert: () => insertChain,
|
||||||
select: () => queryChain,
|
select: () => queryChain,
|
||||||
},
|
},
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
settings: { key: 'key' },
|
settings: { key: 'key' },
|
||||||
checkinLogs: { accountId: 'accountId', createdAt: 'createdAt' },
|
checkinLogs: { accountId: 'accountId', createdAt: 'createdAt' },
|
||||||
|
|||||||
@@ -21,9 +21,13 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -90,6 +94,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -107,6 +111,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
|
|||||||
@@ -25,9 +25,13 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
|||||||
}));
|
}));
|
||||||
const insertProxyLogMock = vi.fn();
|
const insertProxyLogMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -91,6 +95,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ const insertProxyLogMock = vi.fn();
|
|||||||
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn();
|
const resolveProxyUsageWithSelfLogFallbackMock = vi.fn();
|
||||||
const resolveProxyLogBillingMock = vi.fn();
|
const resolveProxyLogBillingMock = vi.fn();
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
|
|||||||
@@ -24,9 +24,13 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
|
|||||||
values: (arg: any) => dbValuesMock(arg),
|
values: (arg: any) => dbValuesMock(arg),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -97,6 +101,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('downstream client context route logging', () => {
|
describe('downstream client context route logging', () => {
|
||||||
|
|||||||
@@ -48,10 +48,13 @@ function createDbSelectChain() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
Response: globalThis.Response,
|
};
|
||||||
}));
|
});
|
||||||
|
|
||||||
vi.mock('../../services/modelPricingService.js', () => ({
|
vi.mock('../../services/modelPricingService.js', () => ({
|
||||||
fetchModelPricingCatalog: (...args: unknown[]) => fetchModelPricingCatalogMock(...args),
|
fetchModelPricingCatalog: (...args: unknown[]) => fetchModelPricingCatalogMock(...args),
|
||||||
@@ -91,6 +94,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
modelAvailability: {
|
modelAvailability: {
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
|
|||||||
@@ -35,9 +35,13 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -109,6 +113,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -88,6 +92,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
|
|||||||
@@ -24,15 +24,20 @@ const resolveProxyUsageWithSelfLogFallbackMock = vi.fn(async ({ usage }: any) =>
|
|||||||
recoveredFromSelfLog: false,
|
recoveredFromSelfLog: false,
|
||||||
}));
|
}));
|
||||||
const trackedClientSockets = new Set<WebSocket>();
|
const trackedClientSockets = new Set<WebSocket>();
|
||||||
|
let siteApiEndpointRows: Array<Record<string, unknown>> = [];
|
||||||
const dbInsertMock = vi.fn((_arg?: any) => ({
|
const dbInsertMock = vi.fn((_arg?: any) => ({
|
||||||
values: () => ({
|
values: () => ({
|
||||||
run: () => undefined,
|
run: () => undefined,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -101,7 +106,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
from: () => ({
|
from: () => ({
|
||||||
where: () => ({
|
where: () => ({
|
||||||
orderBy: () => ({
|
orderBy: () => ({
|
||||||
all: async () => [],
|
all: async () => siteApiEndpointRows,
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
@@ -117,6 +122,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
@@ -156,6 +162,7 @@ function createSelectedChannel(options?: {
|
|||||||
return {
|
return {
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
site: {
|
site: {
|
||||||
|
id: 44,
|
||||||
name: options?.siteName ?? (isCodex ? 'codex-site' : 'openai-site'),
|
name: options?.siteName ?? (isCodex ? 'codex-site' : 'openai-site'),
|
||||||
url: options?.siteUrl ?? (isCodex ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com'),
|
url: options?.siteUrl ?? (isCodex ? 'https://chatgpt.com/backend-api/codex' : 'https://api.openai.com'),
|
||||||
platform: sitePlatform,
|
platform: sitePlatform,
|
||||||
@@ -365,6 +372,7 @@ describe('responses websocket transport', () => {
|
|||||||
reportTokenExpiredMock.mockReset();
|
reportTokenExpiredMock.mockReset();
|
||||||
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
resolveProxyUsageWithSelfLogFallbackMock.mockClear();
|
||||||
dbInsertMock.mockClear();
|
dbInsertMock.mockClear();
|
||||||
|
siteApiEndpointRows = [];
|
||||||
|
|
||||||
const selectedChannel = createSelectedChannel();
|
const selectedChannel = createSelectedChannel();
|
||||||
selectChannelMock.mockReturnValue(selectedChannel);
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
@@ -530,6 +538,92 @@ describe('responses websocket transport', () => {
|
|||||||
expect(upstreamConnectionCount).toBe(1);
|
expect(upstreamConnectionCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the configured site api endpoint pool for codex websocket transport', async () => {
|
||||||
|
siteApiEndpointRows = [{
|
||||||
|
id: 901,
|
||||||
|
siteId: 44,
|
||||||
|
url: upstreamSiteUrl,
|
||||||
|
enabled: true,
|
||||||
|
sortOrder: 0,
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastSelectedAt: null,
|
||||||
|
lastFailedAt: null,
|
||||||
|
lastFailureReason: null,
|
||||||
|
updatedAt: null,
|
||||||
|
}];
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
siteUrl: rejectedUpgradeSiteUrl,
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const messagePromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'response.completed',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const message = await messagePromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(message?.type).toBe('response.completed');
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(0);
|
||||||
|
expect(upstreamConnectionCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the site endpoint exhaustion message on websocket errors', async () => {
|
||||||
|
siteApiEndpointRows = [{
|
||||||
|
id: 902,
|
||||||
|
siteId: 44,
|
||||||
|
url: upstreamSiteUrl,
|
||||||
|
enabled: false,
|
||||||
|
sortOrder: 0,
|
||||||
|
cooldownUntil: null,
|
||||||
|
lastSelectedAt: null,
|
||||||
|
lastFailedAt: null,
|
||||||
|
lastFailureReason: null,
|
||||||
|
updatedAt: null,
|
||||||
|
}];
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
siteUrl: rejectedUpgradeSiteUrl,
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const errorPromise = waitForSocketMessageMatching(
|
||||||
|
socket,
|
||||||
|
(message) => message?.type === 'error',
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const errorMessage = await errorPromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(errorMessage).toMatchObject({
|
||||||
|
type: 'error',
|
||||||
|
status: 408,
|
||||||
|
error: {
|
||||||
|
message: '当前站点的 AI 请求地址均不可用',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(0);
|
||||||
|
expect(upstreamConnectionCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('echoes x-codex-turn-state on websocket upgrade responses', async () => {
|
it('echoes x-codex-turn-state on websocket upgrade responses', async () => {
|
||||||
const socket = createClientSocket(baseUrl, {
|
const socket = createClientSocket(baseUrl, {
|
||||||
'x-codex-turn-state': 'turn-state-123',
|
'x-codex-turn-state': 'turn-state-123',
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
isModelAllowedByPolicyOrAllowedRoutes,
|
isModelAllowedByPolicyOrAllowedRoutes,
|
||||||
type DownstreamTokenAuthSuccess,
|
type DownstreamTokenAuthSuccess,
|
||||||
} from '../../services/downstreamApiKeyService.js';
|
} from '../../services/downstreamApiKeyService.js';
|
||||||
|
import { runWithSiteApiEndpointPool, SiteApiEndpointRequestError } from '../../services/siteApiEndpointService.js';
|
||||||
import { tokenRouter } from '../../services/tokenRouter.js';
|
import { tokenRouter } from '../../services/tokenRouter.js';
|
||||||
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
import { buildOauthProviderHeaders } from '../../services/oauth/service.js';
|
||||||
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
import { openAiResponsesTransformer } from '../../transformers/openai/responses/index.js';
|
||||||
@@ -130,6 +131,18 @@ function selectedChannelSupportsIncrementalInput(
|
|||||||
return selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel);
|
return selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function unwrapCodexWebsocketRuntimeError(error: unknown): CodexWebsocketRuntimeError {
|
||||||
|
if (error instanceof CodexWebsocketRuntimeError) return error;
|
||||||
|
if (error instanceof SiteApiEndpointRequestError && error.cause instanceof CodexWebsocketRuntimeError) {
|
||||||
|
return error.cause;
|
||||||
|
}
|
||||||
|
return new CodexWebsocketRuntimeError(
|
||||||
|
error instanceof Error && error.message.trim()
|
||||||
|
? error.message
|
||||||
|
: 'upstream websocket request failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function shouldReuseSelectedChannel(
|
function shouldReuseSelectedChannel(
|
||||||
selectedChannel: SelectedChannel | null,
|
selectedChannel: SelectedChannel | null,
|
||||||
requestModel: string,
|
requestModel: string,
|
||||||
@@ -628,23 +641,7 @@ async function handleResponsesWebsocketConnection(
|
|||||||
account: codexWebsocketChannel.account,
|
account: codexWebsocketChannel.account,
|
||||||
downstreamHeaders,
|
downstreamHeaders,
|
||||||
});
|
});
|
||||||
const prepared = buildUpstreamEndpointRequest({
|
|
||||||
endpoint: 'responses',
|
|
||||||
modelName: asTrimmedString(codexWebsocketChannel.actualModel) || requestModel,
|
|
||||||
stream: true,
|
|
||||||
tokenValue: codexWebsocketChannel.tokenValue,
|
|
||||||
sitePlatform: codexWebsocketChannel.site.platform,
|
|
||||||
siteUrl: codexWebsocketChannel.site.url,
|
|
||||||
openaiBody: normalized.request,
|
|
||||||
downstreamFormat: 'responses',
|
|
||||||
responsesOriginalBody: normalized.request,
|
|
||||||
downstreamHeaders,
|
|
||||||
providerHeaders,
|
|
||||||
codexExplicitSessionId: deriveCodexExplicitSessionId(normalized.request, websocketSessionId),
|
|
||||||
});
|
|
||||||
const requestUrl = `${codexWebsocketChannel.site.url.replace(/\/+$/, '')}${prepared.path}`;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const websocketRuntimeSessionKey = buildCodexSessionResponseStoreKey({
|
const websocketRuntimeSessionKey = buildCodexSessionResponseStoreKey({
|
||||||
sessionId: websocketSessionId,
|
sessionId: websocketSessionId,
|
||||||
siteId: codexWebsocketChannel.site.id,
|
siteId: codexWebsocketChannel.site.id,
|
||||||
@@ -652,20 +649,51 @@ async function handleResponsesWebsocketConnection(
|
|||||||
channelId: codexWebsocketChannel.channel.id,
|
channelId: codexWebsocketChannel.channel.id,
|
||||||
}) || websocketSessionId;
|
}) || websocketSessionId;
|
||||||
runtimeSessionKeys.add(websocketRuntimeSessionKey);
|
runtimeSessionKeys.add(websocketRuntimeSessionKey);
|
||||||
const runtimeResult = await codexWebsocketRuntime.sendRequest({
|
|
||||||
|
try {
|
||||||
|
const runtimeResult = await runWithSiteApiEndpointPool(
|
||||||
|
codexWebsocketChannel.site as Parameters<typeof runWithSiteApiEndpointPool>[0],
|
||||||
|
async (target) => {
|
||||||
|
const prepared = buildUpstreamEndpointRequest({
|
||||||
|
endpoint: 'responses',
|
||||||
|
modelName: asTrimmedString(codexWebsocketChannel.actualModel) || requestModel,
|
||||||
|
stream: true,
|
||||||
|
tokenValue: codexWebsocketChannel.tokenValue,
|
||||||
|
sitePlatform: codexWebsocketChannel.site.platform,
|
||||||
|
siteUrl: target.baseUrl,
|
||||||
|
openaiBody: normalized.request,
|
||||||
|
downstreamFormat: 'responses',
|
||||||
|
responsesOriginalBody: normalized.request,
|
||||||
|
downstreamHeaders,
|
||||||
|
providerHeaders,
|
||||||
|
codexExplicitSessionId: deriveCodexExplicitSessionId(normalized.request, websocketSessionId),
|
||||||
|
});
|
||||||
|
const requestUrl = `${target.baseUrl.replace(/\/+$/, '')}${prepared.path}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await codexWebsocketRuntime.sendRequest({
|
||||||
sessionId: websocketRuntimeSessionKey,
|
sessionId: websocketRuntimeSessionKey,
|
||||||
requestUrl,
|
requestUrl,
|
||||||
headers: prepared.headers,
|
headers: prepared.headers,
|
||||||
body: prepared.body,
|
body: prepared.body,
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const runtimeError = error instanceof CodexWebsocketRuntimeError
|
||||||
|
? error
|
||||||
|
: new CodexWebsocketRuntimeError('upstream websocket request failed');
|
||||||
|
throw new SiteApiEndpointRequestError(runtimeError.message, {
|
||||||
|
status: runtimeError.status,
|
||||||
|
cause: runtimeError,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
|
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
|
||||||
for (const payload of runtimeResult.events) {
|
for (const payload of runtimeResult.events) {
|
||||||
socket.send(JSON.stringify(payload));
|
socket.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const runtimeError = error instanceof CodexWebsocketRuntimeError
|
const runtimeError = unwrapCodexWebsocketRuntimeError(error);
|
||||||
? error
|
|
||||||
: new CodexWebsocketRuntimeError('upstream websocket request failed');
|
|
||||||
if (runtimeError.status && runtimeError.events.length === 0) {
|
if (runtimeError.status && runtimeError.events.length === 0) {
|
||||||
const forwarded = await forwardResponsesRequestViaHttp({
|
const forwarded = await forwardResponsesRequestViaHttp({
|
||||||
app,
|
app,
|
||||||
|
|||||||
@@ -16,9 +16,13 @@ const dbInsertMock = vi.fn((_arg?: any) => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('undici', () => ({
|
vi.mock('undici', async () => {
|
||||||
|
const actual = await vi.importActual<typeof import('undici')>('undici');
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
fetch: (...args: unknown[]) => fetchMock(...args),
|
fetch: (...args: unknown[]) => fetchMock(...args),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock('../../services/tokenRouter.js', () => ({
|
vi.mock('../../services/tokenRouter.js', () => ({
|
||||||
tokenRouter: {
|
tokenRouter: {
|
||||||
@@ -75,6 +79,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
hasProxyLogBillingDetailsColumn: async () => false,
|
hasProxyLogBillingDetailsColumn: async () => false,
|
||||||
hasProxyLogClientColumns: async () => false,
|
hasProxyLogClientColumns: async () => false,
|
||||||
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
hasProxyLogDownstreamApiKeyIdColumn: async () => false,
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
proxyLogs: {},
|
proxyLogs: {},
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ vi.mock('../../db/index.js', () => ({
|
|||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
hasProxyLogStreamTimingColumns: async () => false,
|
||||||
schema: {
|
schema: {
|
||||||
siteApiEndpoints: {
|
siteApiEndpoints: {
|
||||||
id: {},
|
id: {},
|
||||||
|
|||||||
Reference in New Issue
Block a user