forked from boos/BoosAPI
fix: address codex review findings
This commit is contained in:
@@ -147,6 +147,9 @@ describe('codexWebsocketRuntime', () => {
|
|||||||
id: 'resp-incomplete',
|
id: 'resp-incomplete',
|
||||||
model: parsed.model || 'gpt-5.4',
|
model: parsed.model || 'gpt-5.4',
|
||||||
status: 'incomplete',
|
status: 'incomplete',
|
||||||
|
incomplete_details: {
|
||||||
|
reason: 'max_output_tokens',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
@@ -154,18 +157,34 @@ describe('codexWebsocketRuntime', () => {
|
|||||||
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js');
|
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js');
|
||||||
const runtime = createCodexWebsocketRuntime();
|
const runtime = createCodexWebsocketRuntime();
|
||||||
|
|
||||||
await expect(runtime.sendRequest({
|
let error: unknown;
|
||||||
sessionId: 'exec-session-incomplete',
|
try {
|
||||||
requestUrl: upstreamWsUrl,
|
await runtime.sendRequest({
|
||||||
headers: {
|
sessionId: 'exec-session-incomplete',
|
||||||
Authorization: 'Bearer oauth-access-token',
|
requestUrl: upstreamWsUrl,
|
||||||
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
headers: {
|
||||||
},
|
Authorization: 'Bearer oauth-access-token',
|
||||||
body: {
|
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
||||||
model: 'gpt-5.4',
|
},
|
||||||
input: [],
|
body: {
|
||||||
},
|
model: 'gpt-5.4',
|
||||||
})).rejects.toBeInstanceOf(CodexWebsocketRuntimeError);
|
input: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(CodexWebsocketRuntimeError);
|
||||||
|
expect(error).toMatchObject({
|
||||||
|
message: 'max_output_tokens',
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
expect((error as CodexWebsocketRuntimeError).events).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'response.incomplete',
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('treats top-level error frames as terminal websocket failures', async () => {
|
it('treats top-level error frames as terminal websocket failures', async () => {
|
||||||
@@ -182,17 +201,36 @@ describe('codexWebsocketRuntime', () => {
|
|||||||
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js');
|
const { createCodexWebsocketRuntime, CodexWebsocketRuntimeError } = await import('./codexWebsocketRuntime.js');
|
||||||
const runtime = createCodexWebsocketRuntime();
|
const runtime = createCodexWebsocketRuntime();
|
||||||
|
|
||||||
await expect(runtime.sendRequest({
|
let error: unknown;
|
||||||
sessionId: 'exec-session-error',
|
try {
|
||||||
requestUrl: upstreamWsUrl,
|
await runtime.sendRequest({
|
||||||
headers: {
|
sessionId: 'exec-session-error',
|
||||||
Authorization: 'Bearer oauth-access-token',
|
requestUrl: upstreamWsUrl,
|
||||||
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
headers: {
|
||||||
},
|
Authorization: 'Bearer oauth-access-token',
|
||||||
body: {
|
'OpenAI-Beta': 'responses_websockets=2026-02-06',
|
||||||
model: 'gpt-5.4',
|
},
|
||||||
input: [],
|
body: {
|
||||||
},
|
model: 'gpt-5.4',
|
||||||
})).rejects.toBeInstanceOf(CodexWebsocketRuntimeError);
|
input: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (caught) {
|
||||||
|
error = caught;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(error).toBeInstanceOf(CodexWebsocketRuntimeError);
|
||||||
|
expect(error).toMatchObject({
|
||||||
|
message: 'account mismatch',
|
||||||
|
status: 502,
|
||||||
|
});
|
||||||
|
expect((error as CodexWebsocketRuntimeError).events).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'error',
|
||||||
|
error: expect.objectContaining({
|
||||||
|
message: 'account mismatch',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -34,6 +34,32 @@ function isFailureTerminalEvent(payload: Record<string, unknown>): boolean {
|
|||||||
return type === 'response.failed' || type === 'response.incomplete' || type === 'error';
|
return type === 'response.failed' || type === 'response.incomplete' || type === 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function asFiniteNumber(value: unknown): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFailureTerminalStatus(payload: Record<string, unknown>): number {
|
||||||
|
const response = isRecord(payload.response) ? payload.response : null;
|
||||||
|
const responseError = response && isRecord(response.error) ? response.error : null;
|
||||||
|
const topLevelError = isRecord(payload.error) ? payload.error : null;
|
||||||
|
const candidates = [
|
||||||
|
payload.status,
|
||||||
|
payload.statusCode,
|
||||||
|
payload.code,
|
||||||
|
topLevelError?.status,
|
||||||
|
topLevelError?.statusCode,
|
||||||
|
topLevelError?.code,
|
||||||
|
responseError?.status,
|
||||||
|
responseError?.statusCode,
|
||||||
|
responseError?.code,
|
||||||
|
];
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const status = asFiniteNumber(candidate);
|
||||||
|
if (status !== undefined) return status;
|
||||||
|
}
|
||||||
|
return 502;
|
||||||
|
}
|
||||||
|
|
||||||
function extractTerminalErrorMessage(payload: Record<string, unknown>): string {
|
function extractTerminalErrorMessage(payload: Record<string, unknown>): string {
|
||||||
const type = asTrimmedString(payload.type);
|
const type = asTrimmedString(payload.type);
|
||||||
if (type === 'error' && isRecord(payload.error)) {
|
if (type === 'error' && isRecord(payload.error)) {
|
||||||
@@ -243,7 +269,7 @@ async function sendSessionRequest(
|
|||||||
void closeSocket(socket);
|
void closeSocket(socket);
|
||||||
reject(new CodexWebsocketRuntimeError(extractTerminalErrorMessage(parsed), {
|
reject(new CodexWebsocketRuntimeError(extractTerminalErrorMessage(parsed), {
|
||||||
events: [...events],
|
events: [...events],
|
||||||
status: typeof parsed.status === 'number' && Number.isFinite(parsed.status) ? parsed.status : undefined,
|
status: extractFailureTerminalStatus(parsed),
|
||||||
payload: parsed,
|
payload: parsed,
|
||||||
}));
|
}));
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -135,6 +135,19 @@ function carriesResponsesFileUrlInput(value: unknown): boolean {
|
|||||||
return Object.values(value).some((entry) => carriesResponsesFileUrlInput(entry));
|
return Object.values(value).some((entry) => carriesResponsesFileUrlInput(entry));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldRefreshOauthResponsesRequest(input: {
|
||||||
|
oauthProvider?: string;
|
||||||
|
status: number;
|
||||||
|
response: Response;
|
||||||
|
rawErrText: string;
|
||||||
|
}): boolean {
|
||||||
|
if (input.status === 401) return true;
|
||||||
|
if (input.status !== 403 || input.oauthProvider !== 'codex') return false;
|
||||||
|
const authenticate = input.response.headers.get('www-authenticate') || '';
|
||||||
|
const combined = `${authenticate}\n${input.rawErrText || ''}`;
|
||||||
|
return /\b(invalid_token|expired_token|expired|invalid|unauthorized|account mismatch|authentication)\b/i.test(combined);
|
||||||
|
}
|
||||||
|
|
||||||
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
type UsageSummary = ReturnType<typeof parseProxyUsage>;
|
||||||
|
|
||||||
export async function handleOpenAiResponsesSurfaceRequest(
|
export async function handleOpenAiResponsesSurfaceRequest(
|
||||||
@@ -314,7 +327,12 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
|||||||
dispatchRequest,
|
dispatchRequest,
|
||||||
});
|
});
|
||||||
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
const tryRecover = async (ctx: Parameters<NonNullable<typeof endpointStrategy.tryRecover>>[0]) => {
|
||||||
if ((ctx.response.status === 401 || ctx.response.status === 403) && oauth) {
|
if (oauth && shouldRefreshOauthResponsesRequest({
|
||||||
|
oauthProvider: oauth.provider,
|
||||||
|
status: ctx.response.status,
|
||||||
|
response: ctx.response,
|
||||||
|
rawErrText: ctx.rawErrText || '',
|
||||||
|
})) {
|
||||||
try {
|
try {
|
||||||
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
const refreshed = await refreshOauthAccessTokenSingleflight(selected.account.id);
|
||||||
selected.tokenValue = refreshed.accessToken;
|
selected.tokenValue = refreshed.accessToken;
|
||||||
|
|||||||
@@ -19,6 +19,16 @@ function buildJwt(payload: Record<string, unknown>) {
|
|||||||
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
|
return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.signature`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
describe('oauth routes', { timeout: 15_000 }, () => {
|
describe('oauth routes', { timeout: 15_000 }, () => {
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
let db: DbModule['db'];
|
let db: DbModule['db'];
|
||||||
@@ -599,6 +609,118 @@ describe('oauth routes', { timeout: 15_000 }, () => {
|
|||||||
idToken: originalJwt,
|
idToken: originalJwt,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const accounts = await db.select().from(schema.accounts).all();
|
||||||
|
expect(accounts).toHaveLength(1);
|
||||||
|
expect(accounts[0]?.oauthAccountKey).toBe('chatgpt-account-existing');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps the existing codex account non-active while rebind model discovery is still pending', async () => {
|
||||||
|
const originalJwt = buildJwt({
|
||||||
|
email: 'codex-existing@example.com',
|
||||||
|
'https://api.openai.com/auth': {
|
||||||
|
chatgpt_account_id: 'chatgpt-account-existing',
|
||||||
|
chatgpt_plan_type: 'team',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'ChatGPT Codex OAuth',
|
||||||
|
url: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
const existing = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'codex-existing@example.com',
|
||||||
|
accessToken: 'stable-access-token',
|
||||||
|
apiToken: null,
|
||||||
|
status: 'active',
|
||||||
|
oauthProvider: 'codex',
|
||||||
|
oauthAccountKey: 'chatgpt-account-existing',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'chatgpt-account-existing',
|
||||||
|
accountKey: 'chatgpt-account-existing',
|
||||||
|
email: 'codex-existing@example.com',
|
||||||
|
planType: 'team',
|
||||||
|
refreshToken: 'stable-refresh-token',
|
||||||
|
idToken: originalJwt,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const reboundJwt = buildJwt({
|
||||||
|
email: 'codex-existing@example.com',
|
||||||
|
'https://api.openai.com/auth': {
|
||||||
|
chatgpt_account_id: 'chatgpt-account-rebound',
|
||||||
|
chatgpt_plan_type: 'plus',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const discoveryGate = createDeferred<ResponseLike>();
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
access_token: 'rebound-access-token',
|
||||||
|
refresh_token: 'rebound-refresh-token',
|
||||||
|
id_token: reboundJwt,
|
||||||
|
expires_in: 3600,
|
||||||
|
token_type: 'Bearer',
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
})
|
||||||
|
.mockImplementationOnce(() => discoveryGate.promise);
|
||||||
|
|
||||||
|
const startResponse = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/oauth/providers/codex/start',
|
||||||
|
headers: {
|
||||||
|
host: 'metapi.example',
|
||||||
|
'x-forwarded-proto': 'https',
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
accountId: existing.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const startBody = startResponse.json() as { state: string };
|
||||||
|
|
||||||
|
const callbackPromise = app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `/api/oauth/sessions/${encodeURIComponent(startBody.state)}/manual-callback`,
|
||||||
|
payload: {
|
||||||
|
callbackUrl: `http://localhost:1455/auth/callback?state=${encodeURIComponent(startBody.state)}&code=oauth-code-rebind-pending`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
while (fetchMock.mock.calls.length < 2) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const pendingRow = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get();
|
||||||
|
expect(pendingRow?.status).toBe('disabled');
|
||||||
|
|
||||||
|
discoveryGate.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
models: [{ id: 'gpt-5.4' }],
|
||||||
|
}),
|
||||||
|
text: async () => JSON.stringify({ ok: true }),
|
||||||
|
} as ResponseLike);
|
||||||
|
|
||||||
|
const callbackResponse = await callbackPromise;
|
||||||
|
expect(callbackResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const stored = await db.select().from(schema.accounts).where(eq(schema.accounts.id, existing.id)).get();
|
||||||
|
expect(stored).toMatchObject({
|
||||||
|
id: existing.id,
|
||||||
|
status: 'active',
|
||||||
|
accessToken: 'rebound-access-token',
|
||||||
|
oauthAccountKey: 'chatgpt-account-rebound',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fails codex oauth onboarding when token exchange does not expose chatgpt_account_id', async () => {
|
it('fails codex oauth onboarding when token exchange does not expose chatgpt_account_id', async () => {
|
||||||
|
|||||||
@@ -255,6 +255,33 @@ describe('responses proxy codex oauth refresh', () => {
|
|||||||
expect(response.json()?.output_text).toContain('ok after codex forbidden refresh');
|
expect(response.json()?.output_text).toContain('ok after codex forbidden refresh');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not refresh codex oauth token on non-auth 403 responses', async () => {
|
||||||
|
fetchMock.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||||
|
error: { message: 'quota exceeded for workspace', type: 'usage_limit_reached' },
|
||||||
|
}), {
|
||||||
|
status: 403,
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/v1/responses',
|
||||||
|
payload: {
|
||||||
|
model: 'gpt-5.2-codex',
|
||||||
|
input: 'hello codex',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(refreshOauthAccessTokenSingleflightMock).not.toHaveBeenCalled();
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(response.json()).toMatchObject({
|
||||||
|
error: {
|
||||||
|
message: expect.stringContaining('quota exceeded for workspace'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('retries oauth responses requests with a normalized upstream URL after refresh', async () => {
|
it('retries oauth responses requests with a normalized upstream URL after refresh', async () => {
|
||||||
selectChannelMock.mockReturnValue({
|
selectChannelMock.mockReturnValue({
|
||||||
channel: { id: 11, routeId: 22 },
|
channel: { id: 11, routeId: 22 },
|
||||||
|
|||||||
@@ -194,6 +194,16 @@ function waitForSocketMessages(socket: WebSocket, count: number, timeoutMs = 100
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createDeferred<T>() {
|
||||||
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||||
|
let reject!: (reason?: unknown) => void;
|
||||||
|
const promise = new Promise<T>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
function createClientSocket(baseUrl: string, headers: Record<string, string> = {}) {
|
function createClientSocket(baseUrl: string, headers: Record<string, string> = {}) {
|
||||||
return new WebSocket(`${baseUrl}/v1/responses`, {
|
return new WebSocket(`${baseUrl}/v1/responses`, {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -203,6 +213,10 @@ function createClientSocket(baseUrl: string, headers: Record<string, string> = {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createClientSocketForPath(path: string, headers: Record<string, string> = {}) {
|
||||||
|
return new WebSocket(path, { headers });
|
||||||
|
}
|
||||||
|
|
||||||
describe('responses websocket transport', () => {
|
describe('responses websocket transport', () => {
|
||||||
const originalCodexResponsesWebsocketBeta = config.codexResponsesWebsocketBeta;
|
const originalCodexResponsesWebsocketBeta = config.codexResponsesWebsocketBeta;
|
||||||
let app: FastifyInstance;
|
let app: FastifyInstance;
|
||||||
@@ -545,6 +559,54 @@ describe('responses websocket transport', () => {
|
|||||||
expect(messages[0]?.response?.id).toBe('resp_http_fallback_401');
|
expect(messages[0]?.response?.id).toBe('resp_http_fallback_401');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('preserves query parameter auth when websocket transport falls back to the HTTP responses route', async () => {
|
||||||
|
rejectedUpgradeStatus = 401;
|
||||||
|
rejectedUpgradeStatusText = 'Unauthorized';
|
||||||
|
rejectedUpgradeBody = JSON.stringify({
|
||||||
|
error: {
|
||||||
|
message: 'expired token',
|
||||||
|
type: 'invalid_request_error',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
siteUrl: rejectedUpgradeSiteUrl,
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
authorizeDownstreamTokenMock.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
source: 'global',
|
||||||
|
token: 'sk-query-auth',
|
||||||
|
key: null,
|
||||||
|
policy: {
|
||||||
|
supportedModels: [],
|
||||||
|
allowedRouteIds: [],
|
||||||
|
siteWeightMultipliers: {},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
fetchMock.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_http_fallback_query","model":"gpt-5.4","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const socket = createClientSocketForPath(`${baseUrl}/v1/responses?key=sk-query-auth`);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
const messagesPromise = waitForSocketMessages(socket, 1);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
input: [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const messages = await messagesPromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(messages[0]?.response?.id).toBe('resp_http_fallback_query');
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects websocket turns whose model is blocked by the downstream key policy before channel selection', async () => {
|
it('rejects websocket turns whose model is blocked by the downstream key policy before channel selection', async () => {
|
||||||
authorizeDownstreamTokenMock.mockResolvedValueOnce({
|
authorizeDownstreamTokenMock.mockResolvedValueOnce({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -678,6 +740,111 @@ describe('responses websocket transport', () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('serializes websocket messages per connection so follow-up turns wait for the previous HTTP fallback to finish', async () => {
|
||||||
|
const selectedChannel = createSelectedChannel({
|
||||||
|
sitePlatform: 'openai',
|
||||||
|
actualModel: 'gpt-4.1',
|
||||||
|
});
|
||||||
|
selectChannelMock.mockReturnValue(selectedChannel);
|
||||||
|
previewSelectedChannelMock.mockResolvedValue(selectedChannel);
|
||||||
|
const firstResponseGate = createDeferred<Response>();
|
||||||
|
fetchMock
|
||||||
|
.mockImplementationOnce(() => firstResponseGate.promise)
|
||||||
|
.mockResolvedValueOnce(createSseResponse([
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_ws_2","model":"gpt-4.1","status":"completed","output":[{"id":"msg_2","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"done"}]}],"usage":{"input_tokens":5,"output_tokens":1,"total_tokens":6}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const socket = createClientSocket(baseUrl);
|
||||||
|
await waitForSocketOpen(socket);
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
model: 'gpt-4.1',
|
||||||
|
instructions: 'be helpful',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
id: 'msg_user_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
while (fetchMock.mock.calls.length < 1) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
socket.send(JSON.stringify({
|
||||||
|
type: 'response.create',
|
||||||
|
previous_response_id: 'resp_ws_1',
|
||||||
|
input: [
|
||||||
|
{
|
||||||
|
id: 'tool_out_1',
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call_1',
|
||||||
|
output: 'tool result',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
const messagesPromise = waitForSocketMessages(socket, 4);
|
||||||
|
firstResponseGate.resolve(createSseResponse([
|
||||||
|
'event: response.output_item.done\n',
|
||||||
|
'data: {"type":"response.output_item.done","output_index":0,"item":{"id":"fc_1","type":"function_call","call_id":"call_1"}}\n\n',
|
||||||
|
'event: response.output_item.done\n',
|
||||||
|
'data: {"type":"response.output_item.done","output_index":1,"item":{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}}\n\n',
|
||||||
|
'event: response.completed\n',
|
||||||
|
'data: {"type":"response.completed","response":{"id":"resp_ws_1","model":"gpt-4.1","status":"completed","output":[{"id":"fc_1","type":"function_call","call_id":"call_1"},{"id":"msg_1","type":"message","role":"assistant","status":"completed","content":[{"type":"output_text","text":"call tool"}]}],"usage":{"input_tokens":3,"output_tokens":1,"total_tokens":4}}}\n\n',
|
||||||
|
'data: [DONE]\n\n',
|
||||||
|
]));
|
||||||
|
|
||||||
|
const messages = await messagesPromise;
|
||||||
|
socket.close();
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(messages.map((message) => message.type)).toEqual([
|
||||||
|
'response.output_item.done',
|
||||||
|
'response.output_item.done',
|
||||||
|
'response.completed',
|
||||||
|
'response.completed',
|
||||||
|
]);
|
||||||
|
const [, secondOptions] = fetchMock.mock.calls[1] as [string, RequestInit];
|
||||||
|
const secondBody = JSON.parse(String(secondOptions.body));
|
||||||
|
expect(secondBody.input).toEqual([
|
||||||
|
{
|
||||||
|
id: 'msg_user_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'user',
|
||||||
|
content: [{ type: 'input_text', text: 'call the tool' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'fc_1',
|
||||||
|
type: 'function_call',
|
||||||
|
call_id: 'call_1',
|
||||||
|
status: 'completed',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'msg_1',
|
||||||
|
type: 'message',
|
||||||
|
role: 'assistant',
|
||||||
|
status: 'completed',
|
||||||
|
content: [{ type: 'output_text', text: 'call tool' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tool_out_1',
|
||||||
|
type: 'function_call_output',
|
||||||
|
call_id: 'call_1',
|
||||||
|
output: 'tool result',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
|
it('preserves incremental response.create payloads with previous_response_id for websocket-capable upstreams', async () => {
|
||||||
const selectedChannel = createSelectedChannel({
|
const selectedChannel = createSelectedChannel({
|
||||||
siteUrl: upstreamSiteUrl,
|
siteUrl: upstreamSiteUrl,
|
||||||
|
|||||||
@@ -339,15 +339,25 @@ async function forwardResponsesRequestViaHttp(input: {
|
|||||||
request: IncomingMessage;
|
request: IncomingMessage;
|
||||||
payload: Record<string, unknown>;
|
payload: Record<string, unknown>;
|
||||||
preserveIncrementalMode: boolean;
|
preserveIncrementalMode: boolean;
|
||||||
|
authToken: string;
|
||||||
}): Promise<unknown[] | null> {
|
}): Promise<unknown[] | null> {
|
||||||
|
const injectHeaders = {
|
||||||
|
...buildInjectHeaders(input.request),
|
||||||
|
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
|
||||||
|
...(input.preserveIncrementalMode ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
!headerValueToTrimmedString(injectHeaders.authorization)
|
||||||
|
&& !headerValueToTrimmedString(injectHeaders['x-api-key'])
|
||||||
|
&& !headerValueToTrimmedString(injectHeaders['x-goog-api-key'])
|
||||||
|
) {
|
||||||
|
injectHeaders.authorization = `Bearer ${input.authToken}`;
|
||||||
|
}
|
||||||
|
|
||||||
const response = await input.app.inject({
|
const response = await input.app.inject({
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
url: '/v1/responses',
|
url: '/v1/responses',
|
||||||
headers: {
|
headers: injectHeaders,
|
||||||
...buildInjectHeaders(input.request),
|
|
||||||
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
|
|
||||||
...(input.preserveIncrementalMode ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
|
|
||||||
},
|
|
||||||
payload: input.payload,
|
payload: input.payload,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -444,7 +454,7 @@ function writeUpgradeHttpError(socket: Duplex, status: number, message: string):
|
|||||||
? 'Bad Request'
|
? 'Bad Request'
|
||||||
: 'Error';
|
: 'Error';
|
||||||
const body = JSON.stringify({ error: message });
|
const body = JSON.stringify({ error: message });
|
||||||
socket.write(
|
socket.end(
|
||||||
`HTTP/1.1 ${status} ${statusText}\r\n`
|
`HTTP/1.1 ${status} ${statusText}\r\n`
|
||||||
+ 'Content-Type: application/json\r\n'
|
+ 'Content-Type: application/json\r\n'
|
||||||
+ `Content-Length: ${Buffer.byteLength(body)}\r\n`
|
+ `Content-Length: ${Buffer.byteLength(body)}\r\n`
|
||||||
@@ -452,7 +462,6 @@ function writeUpgradeHttpError(socket: Duplex, status: number, message: string):
|
|||||||
+ '\r\n'
|
+ '\r\n'
|
||||||
+ body,
|
+ body,
|
||||||
);
|
);
|
||||||
socket.destroy();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function supportsResponsesWebsocketIncrementalInput(
|
async function supportsResponsesWebsocketIncrementalInput(
|
||||||
@@ -483,148 +492,155 @@ async function handleResponsesWebsocketConnection(
|
|||||||
let lastRequest: Record<string, unknown> | null = null;
|
let lastRequest: Record<string, unknown> | null = null;
|
||||||
let lastResponseOutput: unknown[] = [];
|
let lastResponseOutput: unknown[] = [];
|
||||||
let selectedChannel: SelectedChannel | null = null;
|
let selectedChannel: SelectedChannel | null = null;
|
||||||
|
let messageQueue = Promise.resolve();
|
||||||
|
|
||||||
socket.once('close', () => {
|
socket.once('close', () => {
|
||||||
void codexWebsocketRuntime.closeSession(websocketSessionId);
|
void codexWebsocketRuntime.closeSession(websocketSessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('message', async (raw) => {
|
socket.on('message', (raw) => {
|
||||||
try {
|
messageQueue = messageQueue
|
||||||
const parsed = parseJsonObject(raw);
|
.catch(() => undefined)
|
||||||
if (!parsed) {
|
.then(async () => {
|
||||||
writeResponsesWebsocketError(socket, 400, 'Invalid websocket JSON payload');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model);
|
|
||||||
if (requestModel && !await isModelAllowedByPolicyOrAllowedRoutes(requestModel, authContext.policy)) {
|
|
||||||
writeResponsesWebsocketError(socket, 403, 'model is not allowed for this downstream key');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|
|
||||||
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest, authContext);
|
|
||||||
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
|
|
||||||
parsed,
|
|
||||||
lastRequest,
|
|
||||||
supportsIncrementalInput,
|
|
||||||
);
|
|
||||||
const normalized = normalizeResponsesWebsocketRequest(
|
|
||||||
parsed,
|
|
||||||
lastRequest,
|
|
||||||
lastResponseOutput,
|
|
||||||
supportsIncrementalInput,
|
|
||||||
);
|
|
||||||
if (!normalized.ok) {
|
|
||||||
writeResponsesWebsocketError(socket, normalized.status, normalized.message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (authContext.source === 'managed' && authContext.key?.id) {
|
|
||||||
await consumeManagedKeyRequest(authContext.key.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
lastRequest = normalized.nextRequestSnapshot;
|
|
||||||
if (shouldHandleLocalPrewarm) {
|
|
||||||
lastResponseOutput = [];
|
|
||||||
for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) {
|
|
||||||
socket.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!shouldReuseSelectedChannel(selectedChannel, requestModel)) {
|
|
||||||
selectedChannel = requestModel
|
|
||||||
? await tokenRouter.selectChannel(requestModel, authContext.policy)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
|
|
||||||
? selectedChannel
|
|
||||||
: null;
|
|
||||||
|
|
||||||
if (codexWebsocketChannel) {
|
|
||||||
const downstreamHeaders: Record<string, unknown> = {
|
|
||||||
...(request.headers as Record<string, unknown>),
|
|
||||||
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
|
|
||||||
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
|
|
||||||
};
|
|
||||||
const providerHeaders = buildOauthProviderHeaders({
|
|
||||||
extraConfig: typeof codexWebsocketChannel.account.extraConfig === 'string'
|
|
||||||
? codexWebsocketChannel.account.extraConfig
|
|
||||||
: null,
|
|
||||||
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 {
|
try {
|
||||||
const runtimeResult = await codexWebsocketRuntime.sendRequest({
|
const parsed = parseJsonObject(raw);
|
||||||
sessionId: websocketSessionId,
|
if (!parsed) {
|
||||||
requestUrl,
|
writeResponsesWebsocketError(socket, 400, 'Invalid websocket JSON payload');
|
||||||
headers: prepared.headers,
|
return;
|
||||||
body: prepared.body,
|
|
||||||
});
|
|
||||||
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
|
|
||||||
for (const payload of runtimeResult.events) {
|
|
||||||
socket.send(JSON.stringify(payload));
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
|
||||||
const runtimeError = error instanceof CodexWebsocketRuntimeError
|
const requestModel = asTrimmedString(parsed.model) || asTrimmedString(lastRequest?.model);
|
||||||
? error
|
if (requestModel && !await isModelAllowedByPolicyOrAllowedRoutes(requestModel, authContext.policy)) {
|
||||||
: new CodexWebsocketRuntimeError('upstream websocket request failed');
|
writeResponsesWebsocketError(socket, 403, 'model is not allowed for this downstream key');
|
||||||
if (runtimeError.status && runtimeError.events.length === 0) {
|
return;
|
||||||
const fallbackOutput = await forwardResponsesRequestViaHttp({
|
}
|
||||||
app,
|
const supportsIncrementalInput = selectedChannelSupportsIncrementalInput(selectedChannel, requestModel)
|
||||||
socket,
|
|| await supportsResponsesWebsocketIncrementalInput(parsed, lastRequest, authContext);
|
||||||
request,
|
const shouldHandleLocalPrewarm = shouldHandleResponsesWebsocketPrewarmLocally(
|
||||||
payload: normalized.request,
|
parsed,
|
||||||
preserveIncrementalMode: false,
|
lastRequest,
|
||||||
});
|
supportsIncrementalInput,
|
||||||
if (fallbackOutput) {
|
);
|
||||||
lastResponseOutput = fallbackOutput;
|
const normalized = normalizeResponsesWebsocketRequest(
|
||||||
|
parsed,
|
||||||
|
lastRequest,
|
||||||
|
lastResponseOutput,
|
||||||
|
supportsIncrementalInput,
|
||||||
|
);
|
||||||
|
if (!normalized.ok) {
|
||||||
|
writeResponsesWebsocketError(socket, normalized.status, normalized.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authContext.source === 'managed' && authContext.key?.id) {
|
||||||
|
await consumeManagedKeyRequest(authContext.key.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastRequest = normalized.nextRequestSnapshot;
|
||||||
|
if (shouldHandleLocalPrewarm) {
|
||||||
|
lastResponseOutput = [];
|
||||||
|
for (const payload of synthesizePrewarmResponsePayloads(normalized.request)) {
|
||||||
|
socket.send(JSON.stringify(payload));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
lastResponseOutput = collectResponsesOutput(runtimeError.events);
|
|
||||||
for (const payload of runtimeError.events) {
|
|
||||||
socket.send(JSON.stringify(payload));
|
|
||||||
}
|
|
||||||
writeResponsesWebsocketError(
|
|
||||||
socket,
|
|
||||||
runtimeError.status || 408,
|
|
||||||
runtimeError.message,
|
|
||||||
runtimeError.payload,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const forwardedOutput = await forwardResponsesRequestViaHttp({
|
if (!shouldReuseSelectedChannel(selectedChannel, requestModel)) {
|
||||||
app,
|
selectedChannel = requestModel
|
||||||
socket,
|
? await tokenRouter.selectChannel(requestModel, authContext.policy)
|
||||||
request,
|
: null;
|
||||||
payload: normalized.request,
|
}
|
||||||
preserveIncrementalMode: supportsIncrementalInput,
|
|
||||||
|
const codexWebsocketChannel = selectedChannelSupportsCodexWebsocketTransport(selectedChannel, requestModel)
|
||||||
|
? selectedChannel
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (codexWebsocketChannel) {
|
||||||
|
const downstreamHeaders: Record<string, unknown> = {
|
||||||
|
...(request.headers as Record<string, unknown>),
|
||||||
|
[RESPONSES_WEBSOCKET_TRANSPORT_HEADER]: '1',
|
||||||
|
...(supportsIncrementalInput ? { [RESPONSES_WEBSOCKET_MODE_HEADER]: 'incremental' } : {}),
|
||||||
|
};
|
||||||
|
const providerHeaders = buildOauthProviderHeaders({
|
||||||
|
extraConfig: typeof codexWebsocketChannel.account.extraConfig === 'string'
|
||||||
|
? codexWebsocketChannel.account.extraConfig
|
||||||
|
: null,
|
||||||
|
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 runtimeResult = await codexWebsocketRuntime.sendRequest({
|
||||||
|
sessionId: websocketSessionId,
|
||||||
|
requestUrl,
|
||||||
|
headers: prepared.headers,
|
||||||
|
body: prepared.body,
|
||||||
|
});
|
||||||
|
lastResponseOutput = collectResponsesOutput(runtimeResult.events);
|
||||||
|
for (const payload of runtimeResult.events) {
|
||||||
|
socket.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const runtimeError = error instanceof CodexWebsocketRuntimeError
|
||||||
|
? error
|
||||||
|
: new CodexWebsocketRuntimeError('upstream websocket request failed');
|
||||||
|
if (runtimeError.status && runtimeError.events.length === 0) {
|
||||||
|
const fallbackOutput = await forwardResponsesRequestViaHttp({
|
||||||
|
app,
|
||||||
|
socket,
|
||||||
|
request,
|
||||||
|
payload: normalized.request,
|
||||||
|
preserveIncrementalMode: false,
|
||||||
|
authToken: authContext.token,
|
||||||
|
});
|
||||||
|
if (fallbackOutput) {
|
||||||
|
lastResponseOutput = fallbackOutput;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastResponseOutput = collectResponsesOutput(runtimeError.events);
|
||||||
|
for (const payload of runtimeError.events) {
|
||||||
|
socket.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
writeResponsesWebsocketError(
|
||||||
|
socket,
|
||||||
|
runtimeError.status || 408,
|
||||||
|
runtimeError.message,
|
||||||
|
runtimeError.payload,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const forwardedOutput = await forwardResponsesRequestViaHttp({
|
||||||
|
app,
|
||||||
|
socket,
|
||||||
|
request,
|
||||||
|
payload: normalized.request,
|
||||||
|
preserveIncrementalMode: supportsIncrementalInput,
|
||||||
|
authToken: authContext.token,
|
||||||
|
});
|
||||||
|
if (forwardedOutput) {
|
||||||
|
lastResponseOutput = forwardedOutput;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
writeResponsesWebsocketError(socket, 500, 'internal websocket proxy error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (forwardedOutput) {
|
|
||||||
lastResponseOutput = forwardedOutput;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
writeResponsesWebsocketError(socket, 500, 'internal websocket proxy error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -481,7 +481,10 @@ function buildSuccessfulRefreshResult(input: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshModelsForAccount(accountId: number): Promise<ModelRefreshResult> {
|
export async function refreshModelsForAccount(
|
||||||
|
accountId: number,
|
||||||
|
options?: { allowInactive?: boolean },
|
||||||
|
): Promise<ModelRefreshResult> {
|
||||||
const row = await db.select().from(schema.accounts)
|
const row = await db.select().from(schema.accounts)
|
||||||
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
.innerJoin(schema.sites, eq(schema.accounts.siteId, schema.sites.id))
|
||||||
.where(eq(schema.accounts.id, accountId))
|
.where(eq(schema.accounts.id, accountId))
|
||||||
@@ -516,7 +519,7 @@ export async function refreshModelsForAccount(accountId: number): Promise<ModelR
|
|||||||
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
|
return buildSkippedRefreshResult(accountId, 'site_disabled', '站点已禁用');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (account.status !== 'active') {
|
if (account.status !== 'active' && !options?.allowInactive) {
|
||||||
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
return buildSkippedRefreshResult(accountId, 'adapter_or_status', '平台不可用或账号未激活');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ async function upsertOauthAccount(input: {
|
|||||||
accessToken: input.exchange.accessToken,
|
accessToken: input.exchange.accessToken,
|
||||||
apiToken: null,
|
apiToken: null,
|
||||||
checkinEnabled: false,
|
checkinEnabled: false,
|
||||||
status: 'active',
|
status: 'disabled',
|
||||||
oauthProvider: input.definition.metadata.provider,
|
oauthProvider: input.definition.metadata.provider,
|
||||||
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
oauthAccountKey: oauth.accountKey || oauth.accountId || null,
|
||||||
oauthProjectId: oauth.projectId || null,
|
oauthProjectId: oauth.projectId || null,
|
||||||
@@ -376,7 +376,10 @@ export async function handleOauthCallback(input: {
|
|||||||
throw new Error('failed to persist oauth account');
|
throw new Error('failed to persist oauth account');
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshResult = await refreshModelsForAccount(account.id);
|
const refreshResult = await refreshModelsForAccount(
|
||||||
|
account.id,
|
||||||
|
previousAccount ? { allowInactive: true } : undefined,
|
||||||
|
);
|
||||||
if (refreshResult.status !== 'success') {
|
if (refreshResult.status !== 'success') {
|
||||||
if (created) {
|
if (created) {
|
||||||
await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
|
await db.delete(schema.accounts).where(eq(schema.accounts.id, account.id)).run();
|
||||||
@@ -401,6 +404,13 @@ export async function handleOauthCallback(input: {
|
|||||||
throw new Error(errorMessage);
|
throw new Error(errorMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (previousAccount) {
|
||||||
|
await db.update(schema.accounts).set({
|
||||||
|
status: 'active',
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
}).where(eq(schema.accounts.id, account.id)).run();
|
||||||
|
}
|
||||||
|
|
||||||
await rebuildTokenRoutesFromAvailability();
|
await rebuildTokenRoutesFromAvailability();
|
||||||
markOauthSessionSuccess(input.state, {
|
markOauthSessionSuccess(input.state, {
|
||||||
accountId: account.id,
|
accountId: account.id,
|
||||||
|
|||||||
@@ -209,4 +209,48 @@ describe('TokenRouter site status guard', () => {
|
|||||||
expect(selected).not.toBeNull();
|
expect(selected).not.toBeNull();
|
||||||
expect(selected?.tokenValue).toBe('oauth-access-token');
|
expect(selected?.tokenValue).toBe('oauth-access-token');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not fall back to a stale legacy api token when an oauth-managed row has no access token', async () => {
|
||||||
|
const site = await db.insert(schema.sites).values({
|
||||||
|
name: 'codex-site',
|
||||||
|
url: 'https://chatgpt.com/backend-api/codex',
|
||||||
|
platform: 'codex',
|
||||||
|
status: 'active',
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const account = await db.insert(schema.accounts).values({
|
||||||
|
siteId: site.id,
|
||||||
|
username: 'codex-user@example.com',
|
||||||
|
accessToken: '',
|
||||||
|
apiToken: 'stale-direct-token',
|
||||||
|
status: 'active',
|
||||||
|
extraConfig: JSON.stringify({
|
||||||
|
credentialMode: 'session',
|
||||||
|
oauth: {
|
||||||
|
provider: 'codex',
|
||||||
|
accountId: 'chatgpt-account-123',
|
||||||
|
email: 'codex-user@example.com',
|
||||||
|
planType: 'plus',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
const route = await db.insert(schema.tokenRoutes).values({
|
||||||
|
modelPattern: 'gpt-5.2-codex',
|
||||||
|
enabled: true,
|
||||||
|
}).returning().get();
|
||||||
|
|
||||||
|
await db.insert(schema.routeChannels).values({
|
||||||
|
routeId: route.id,
|
||||||
|
accountId: account.id,
|
||||||
|
tokenId: null,
|
||||||
|
priority: 0,
|
||||||
|
weight: 10,
|
||||||
|
enabled: true,
|
||||||
|
manualOverride: false,
|
||||||
|
}).run();
|
||||||
|
|
||||||
|
const selected = await new TokenRouter().selectChannel('gpt-5.2-codex');
|
||||||
|
expect(selected).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user