[codex] fix responses lifecycle parity and codex session serialization (#278)
* fix responses lifecycle parity * remove websocket test debug logging * fix responses proxy stream typecheck * update compact upstream response expectations * add community and deepwiki badges * update linuxdo banner link * fix responses review follow-ups * preserve whitespace in responses terminal deltas * fix responses stream reconciliation edges * fix responses lifecycle parity and codex session serialization * fix review follow-up edge cases --------- Co-authored-by: apple <apple@appledeMacBook-Pro.local>
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
const codexHttpSessionQueues = new Map<string, Promise<void>>();
|
||||
|
||||
export async function runCodexHttpSessionTask<T>(
|
||||
sessionId: string,
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const normalizedSessionId = sessionId.trim();
|
||||
if (!normalizedSessionId) {
|
||||
return task();
|
||||
}
|
||||
|
||||
const previous = codexHttpSessionQueues.get(normalizedSessionId) || Promise.resolve();
|
||||
const run = previous
|
||||
.catch(() => undefined)
|
||||
.then(() => task());
|
||||
const tail = run.then(() => undefined, () => undefined);
|
||||
codexHttpSessionQueues.set(normalizedSessionId, tail);
|
||||
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (codexHttpSessionQueues.get(normalizedSessionId) === tail) {
|
||||
codexHttpSessionQueues.delete(normalizedSessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resetCodexHttpSessionQueue(): void {
|
||||
codexHttpSessionQueues.clear();
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
unwrapGeminiCliPayload,
|
||||
} from '../../routes/proxy/geminiCliCompat.js';
|
||||
import { summarizeConversationFileInputsInOpenAiBody } from '../capabilities/conversationFileCapabilities.js';
|
||||
import { readRuntimeResponseText } from '../executors/types.js';
|
||||
import { detectDownstreamClientContext } from '../../routes/proxy/downstreamClientContext.js';
|
||||
import { canRetryProxyChannel, getProxyMaxChannelRetries } from '../../services/proxyChannelRetry.js';
|
||||
import {
|
||||
@@ -394,7 +395,7 @@ export async function handleChatSurfaceRequest(
|
||||
});
|
||||
let rawText = '';
|
||||
if (!upstreamContentType.includes('text/event-stream')) {
|
||||
const fallbackText = await upstream.text();
|
||||
const fallbackText = await readRuntimeResponseText(upstream);
|
||||
rawText = fallbackText;
|
||||
if (looksLikeResponsesSseText(fallbackText)) {
|
||||
startSseResponse();
|
||||
@@ -578,7 +579,7 @@ export async function handleChatSurfaceRequest(
|
||||
rawText = collected.rawText;
|
||||
upstreamData = collected.payload;
|
||||
} else {
|
||||
rawText = await upstream.text();
|
||||
rawText = await readRuntimeResponseText(upstream);
|
||||
if (looksLikeResponsesSseText(rawText)) {
|
||||
upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
||||
} else {
|
||||
|
||||
@@ -33,6 +33,8 @@ import {
|
||||
unwrapGeminiCliPayload,
|
||||
} from '../../routes/proxy/geminiCliCompat.js';
|
||||
import { isCodexResponsesSurface } from '../cliProfiles/codexProfile.js';
|
||||
import { readRuntimeResponseText } from '../executors/types.js';
|
||||
import { runCodexHttpSessionTask } from '../runtime/codexHttpSessionQueue.js';
|
||||
import {
|
||||
summarizeConversationFileInputsInOpenAiBody,
|
||||
summarizeConversationFileInputsInResponsesBody,
|
||||
@@ -56,6 +58,15 @@ function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === 'object';
|
||||
}
|
||||
|
||||
function getCodexSessionHeaderValue(headers: Record<string, string>): string {
|
||||
for (const [rawKey, rawValue] of Object.entries(headers)) {
|
||||
const normalizedKey = rawKey.trim().toLowerCase();
|
||||
if (normalizedKey === 'session_id' || normalizedKey === 'session-id') {
|
||||
return String(rawValue || '').trim();
|
||||
}
|
||||
}
|
||||
return '';
|
||||
}
|
||||
function isResponsesWebsocketTransportRequest(headers: Record<string, unknown>): boolean {
|
||||
return Object.entries(headers)
|
||||
.some(([rawKey, rawValue]) => rawKey.trim().toLowerCase() === 'x-metapi-responses-websocket-transport'
|
||||
@@ -324,10 +335,23 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
runtime: endpointRequest.runtime,
|
||||
};
|
||||
};
|
||||
const dispatchRequest = createSurfaceDispatchRequest({
|
||||
const baseDispatchRequest = createSurfaceDispatchRequest({
|
||||
site: selected.site,
|
||||
accountExtraConfig: selected.account.extraConfig,
|
||||
});
|
||||
const dispatchRequest = (
|
||||
endpointRequest: BuiltEndpointRequest,
|
||||
targetUrl?: string,
|
||||
) => {
|
||||
if (!isCodexSite || endpointRequest.path !== '/responses') {
|
||||
return baseDispatchRequest(endpointRequest, targetUrl);
|
||||
}
|
||||
const sessionId = getCodexSessionHeaderValue(endpointRequest.headers);
|
||||
return runCodexHttpSessionTask(
|
||||
sessionId,
|
||||
() => baseDispatchRequest(endpointRequest, targetUrl),
|
||||
);
|
||||
};
|
||||
const endpointStrategy = openAiResponsesTransformer.compatibility.createEndpointStrategy({
|
||||
isStream: isStream || isCodexSite,
|
||||
requiresNativeResponsesFileUrl,
|
||||
@@ -507,7 +531,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
},
|
||||
});
|
||||
if (!upstreamContentType.includes('text/event-stream')) {
|
||||
const rawText = await upstream.text();
|
||||
const rawText = await readRuntimeResponseText(upstream);
|
||||
if (looksLikeResponsesSseText(rawText)) {
|
||||
startSseResponse();
|
||||
const streamResult = await streamSession.run(
|
||||
@@ -614,7 +638,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
|
||||
let replayReader: ReturnType<typeof createSingleChunkStreamReader> | null = null;
|
||||
if (websocketTransportRequest) {
|
||||
const rawText = await upstream.text();
|
||||
const rawText = await readRuntimeResponseText(upstream);
|
||||
if (looksLikeResponsesSseText(rawText)) {
|
||||
try {
|
||||
const collectedPayload = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
||||
@@ -745,7 +769,7 @@ export async function handleOpenAiResponsesSurfaceRequest(
|
||||
rawText = collected.rawText;
|
||||
upstreamData = collected.payload;
|
||||
} else {
|
||||
rawText = await upstream.text();
|
||||
rawText = await readRuntimeResponseText(upstream);
|
||||
if (looksLikeResponsesSseText(rawText)) {
|
||||
upstreamData = collectResponsesFinalPayloadFromSseText(rawText, modelName).payload;
|
||||
} else {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zstdCompressSync } from 'node:zlib';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -191,4 +192,166 @@ describe('testRoutes proxy tester transport', () => {
|
||||
expect(url).toMatch(/\/v1\/files$/);
|
||||
expect(requestInit.body?.constructor?.name).toBe('FormData');
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed buffered proxy test responses', async () => {
|
||||
const payload = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '你好,来自压缩响应',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: false,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '你好,来自压缩响应',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed non-SSE stream fallback responses', async () => {
|
||||
const payload = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: {
|
||||
content: '你好,来自流式回退',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy/stream',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.payload).toBe(`data: ${payload}\n\ndata: [DONE]\n\n`);
|
||||
});
|
||||
|
||||
it('encodes multiline non-SSE stream fallback responses as valid SSE', async () => {
|
||||
const payload = 'line one\nline two\n';
|
||||
fetchMock.mockResolvedValue(new Response(payload, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-type': 'text/plain; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy/stream',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.payload).toBe('data: line one\ndata: line two\ndata: \n\ndata: [DONE]\n\n');
|
||||
});
|
||||
|
||||
it('cancels and releases SSE readers when stream forwarding fails', async () => {
|
||||
const cancel = vi.fn().mockResolvedValue(undefined);
|
||||
const releaseLock = vi.fn();
|
||||
const read = vi.fn().mockRejectedValue(new Error('boom'));
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers({
|
||||
'content-type': 'text/event-stream; charset=utf-8',
|
||||
}),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read,
|
||||
cancel,
|
||||
releaseLock,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/test/proxy/stream',
|
||||
payload: {
|
||||
method: 'POST',
|
||||
path: '/v1/chat/completions',
|
||||
requestKind: 'json',
|
||||
stream: true,
|
||||
jobMode: false,
|
||||
rawMode: false,
|
||||
jsonBody: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'ping' }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.payload).toContain('event: error');
|
||||
expect(response.payload).toContain('"message":"boom"');
|
||||
expect(cancel).toHaveBeenCalledTimes(1);
|
||||
expect(releaseLock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { fetch, File as UndiciFile, FormData as UndiciFormData } from 'undici';
|
||||
import { config } from '../../config.js';
|
||||
import { readRuntimeResponseText } from '../../proxy-core/executors/types.js';
|
||||
|
||||
type UndiciRequestInit = Parameters<typeof fetch>[1];
|
||||
|
||||
@@ -521,7 +522,7 @@ async function fetchProxyBuffered(
|
||||
});
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
const text = await upstream.text();
|
||||
const text = await readRuntimeResponseText(upstream);
|
||||
|
||||
if (!upstream.ok) {
|
||||
throw new UpstreamProxyError(upstream.status, normalizeErrorPayload(text));
|
||||
@@ -656,14 +657,14 @@ async function sendStreamingEnvelope(
|
||||
}
|
||||
|
||||
if (!upstream.ok) {
|
||||
const text = await upstream.text();
|
||||
const text = await readRuntimeResponseText(upstream);
|
||||
cleanupClientListeners();
|
||||
return reply.code(upstream.status).send(normalizeErrorPayload(text));
|
||||
}
|
||||
|
||||
const contentType = upstream.headers.get('content-type') || '';
|
||||
const reader = upstream.body?.getReader();
|
||||
if (!reader) {
|
||||
const reader = contentType.includes('text/event-stream') ? upstream.body?.getReader() : null;
|
||||
if (contentType.includes('text/event-stream') && !reader) {
|
||||
cleanupClientListeners();
|
||||
return reply.code(502).send({
|
||||
error: {
|
||||
@@ -682,25 +683,30 @@ async function sendStreamingEnvelope(
|
||||
|
||||
try {
|
||||
if (contentType.includes('text/event-stream')) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
reply.raw.write(Buffer.from(value));
|
||||
const streamReader = reader!;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
reply.raw.write(Buffer.from(value));
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await streamReader.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
} finally {
|
||||
streamReader.releaseLock();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let text = '';
|
||||
const decoder = new TextDecoder('utf-8');
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
text += decoder.decode(value, { stream: true });
|
||||
}
|
||||
const text = await readRuntimeResponseText(upstream);
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
reply.raw.write(`data: ${line}\n`);
|
||||
}
|
||||
text += decoder.decode();
|
||||
reply.raw.write(`data: ${text}\n\n`);
|
||||
reply.raw.write('\n');
|
||||
reply.raw.write('data: [DONE]\n\n');
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -711,11 +717,6 @@ async function sendStreamingEnvelope(
|
||||
reply.raw.write(`event: error\ndata: ${message}\n\n`);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await reader.cancel();
|
||||
} catch {
|
||||
// no-op
|
||||
}
|
||||
cleanupClientListeners();
|
||||
if (!reply.raw.writableEnded) {
|
||||
reply.raw.end();
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('chat proxy codex oauth compatibility', () => {
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.stream).toBe(true);
|
||||
expect(forwardedBody.instructions).toBe('');
|
||||
expect(forwardedBody.store).toBeUndefined();
|
||||
expect(forwardedBody.store).toBe(false);
|
||||
expect(forwardedBody.parallel_tool_calls).toBeUndefined();
|
||||
expect(forwardedBody.include).toBeUndefined();
|
||||
expect(forwardedBody.max_output_tokens).toBe(256);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { zstdCompressSync } from 'node:zlib';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { config } from '../../config.js';
|
||||
@@ -169,6 +170,79 @@ describe('chat proxy stream behavior', () => {
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed non-stream chat responses before serializing downstream JSON', async () => {
|
||||
const payload = JSON.stringify({
|
||||
id: 'chatcmpl-zstd',
|
||||
object: 'chat.completion',
|
||||
created: 1_706_000_000,
|
||||
model: 'upstream-gpt',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: '你好,来自 zstd 非流式响应' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 },
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-4o-mini',
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()?.choices?.[0]?.message?.content).toBe('你好,来自 zstd 非流式响应');
|
||||
});
|
||||
|
||||
it('decodes zstd-compressed non-SSE streaming chat responses before SSE conversion', async () => {
|
||||
const payload = JSON.stringify({
|
||||
id: 'chatcmpl-zstd-stream',
|
||||
object: 'chat.completion',
|
||||
created: 1_706_000_000,
|
||||
model: 'upstream-gpt',
|
||||
choices: [{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: '你好,来自 zstd 流式回退' },
|
||||
finish_reason: 'stop',
|
||||
}],
|
||||
usage: { prompt_tokens: 11, completion_tokens: 7, total_tokens: 18 },
|
||||
});
|
||||
fetchMock.mockResolvedValue(new Response(zstdCompressSync(Buffer.from(payload)), {
|
||||
status: 200,
|
||||
headers: {
|
||||
'content-encoding': 'zstd',
|
||||
'content-type': 'application/json; charset=utf-8',
|
||||
},
|
||||
}));
|
||||
|
||||
const response = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/chat/completions',
|
||||
payload: {
|
||||
model: 'gpt-4o-mini',
|
||||
stream: true,
|
||||
messages: [{ role: 'user', content: 'hi' }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers['content-type']).toContain('text/event-stream');
|
||||
expect(response.body).toContain('"chat.completion.chunk"');
|
||||
expect(response.body).toContain('你好,来自 zstd 流式回退');
|
||||
expect(response.body).not.toContain('(�/�');
|
||||
expect(response.body).toContain('data: [DONE]');
|
||||
});
|
||||
|
||||
it('returns upstream_error for empty non-stream chat responses when empty-content failure is enabled', async () => {
|
||||
config.proxyEmptyContentFailEnabled = true;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { config } from '../../config.js';
|
||||
import { resetCodexHttpSessionQueue } from '../../proxy-core/runtime/codexHttpSessionQueue.js';
|
||||
|
||||
const fetchMock = vi.fn();
|
||||
const selectChannelMock = vi.fn();
|
||||
@@ -91,6 +92,26 @@ vi.mock('../../db/index.js', () => ({
|
||||
describe('responses proxy codex oauth refresh', () => {
|
||||
let app: FastifyInstance;
|
||||
|
||||
const 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 };
|
||||
};
|
||||
|
||||
const waitFor = async (predicate: () => boolean, timeoutMs = 1000) => {
|
||||
const startedAt = Date.now();
|
||||
while (!predicate()) {
|
||||
if ((Date.now() - startedAt) >= timeoutMs) {
|
||||
throw new Error('Timed out waiting for test condition');
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
};
|
||||
|
||||
const createSseResponse = (chunks: string[], status = 200) => {
|
||||
const encoder = new TextEncoder();
|
||||
return new Response(new ReadableStream<Uint8Array>({
|
||||
@@ -113,6 +134,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resetCodexHttpSessionQueue();
|
||||
config.proxyEmptyContentFailEnabled = false;
|
||||
fetchMock.mockReset();
|
||||
selectChannelMock.mockReset();
|
||||
@@ -461,6 +483,84 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
expect(options.headers.referer).toBeUndefined();
|
||||
});
|
||||
|
||||
it('serializes concurrent codex HTTP responses requests that share the same session id', async () => {
|
||||
const firstUpstream = createDeferred<Response>();
|
||||
fetchMock
|
||||
.mockImplementationOnce(() => firstUpstream.promise)
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
id: 'resp_codex_serial_2',
|
||||
object: 'response',
|
||||
model: 'gpt-5.2-codex',
|
||||
status: 'completed',
|
||||
output_text: 'second request finished',
|
||||
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const firstResponsePromise = app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: {
|
||||
session_id: 'session-http-serial-1',
|
||||
},
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
input: 'first request',
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => fetchMock.mock.calls.length === 1);
|
||||
|
||||
const secondResponsePromise = app.inject({
|
||||
method: 'POST',
|
||||
url: '/v1/responses',
|
||||
headers: {
|
||||
session_id: 'session-http-serial-1',
|
||||
},
|
||||
payload: {
|
||||
model: 'gpt-5.2-codex',
|
||||
input: 'second request',
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
firstUpstream.resolve(new Response(JSON.stringify({
|
||||
id: 'resp_codex_serial_1',
|
||||
object: 'response',
|
||||
model: 'gpt-5.2-codex',
|
||||
status: 'completed',
|
||||
output_text: 'first request finished',
|
||||
usage: { input_tokens: 4, output_tokens: 2, total_tokens: 6 },
|
||||
}), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}));
|
||||
|
||||
const firstResponse = await firstResponsePromise;
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(firstResponse.json()).toMatchObject({
|
||||
output_text: 'first request finished',
|
||||
});
|
||||
|
||||
await waitFor(() => fetchMock.mock.calls.length === 2);
|
||||
|
||||
const secondResponse = await secondResponsePromise;
|
||||
expect(secondResponse.statusCode).toBe(200);
|
||||
expect(secondResponse.json()).toMatchObject({
|
||||
output_text: 'second request finished',
|
||||
});
|
||||
|
||||
const [, firstOptions] = fetchMock.mock.calls[0] as [string, any];
|
||||
const [, secondOptions] = fetchMock.mock.calls[1] as [string, any];
|
||||
expect(firstOptions.headers.Session_id || firstOptions.headers.session_id).toBe('session-http-serial-1');
|
||||
expect(secondOptions.headers.Session_id || secondOptions.headers.session_id).toBe('session-http-serial-1');
|
||||
});
|
||||
|
||||
it('records codex usage_limit_reached reset hints on upstream 429 failures', async () => {
|
||||
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
||||
error: {
|
||||
@@ -525,7 +625,7 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
const forwardedBody = JSON.parse(options.body);
|
||||
expect(forwardedBody.stream).toBe(true);
|
||||
expect(forwardedBody.instructions).toBe('');
|
||||
expect(forwardedBody.store).toBeUndefined();
|
||||
expect(forwardedBody.store).toBe(false);
|
||||
expect(forwardedBody.max_output_tokens).toBeUndefined();
|
||||
|
||||
expect(response.json()).toMatchObject({
|
||||
@@ -568,11 +668,11 @@ describe('responses proxy codex oauth refresh', () => {
|
||||
const secondBody = JSON.parse(secondOptions.body);
|
||||
|
||||
expect(firstBody.instructions).toBe('');
|
||||
expect(firstBody.store).toBeUndefined();
|
||||
expect(firstBody.store).toBe(false);
|
||||
expect(firstBody.stream).toBe(true);
|
||||
expect(firstBody.max_output_tokens).toBeUndefined();
|
||||
expect(secondBody.instructions).toBe('');
|
||||
expect(secondBody.store).toBeUndefined();
|
||||
expect(secondBody.store).toBe(false);
|
||||
expect(secondBody.stream).toBe(true);
|
||||
expect(secondBody.max_output_tokens).toBeUndefined();
|
||||
});
|
||||
|
||||
@@ -628,7 +628,7 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.body.instructions).toBe('');
|
||||
expect(request.body.prompt_cache_key).toBeUndefined();
|
||||
expect(request.body.stream).toBe(false);
|
||||
expect(request.body.store).toBeUndefined();
|
||||
expect(request.body.store).toBe(false);
|
||||
expect(request.body.parallel_tool_calls).toBeUndefined();
|
||||
expect(request.body.include).toBeUndefined();
|
||||
expect(request.body.max_output_tokens).toBe(4096);
|
||||
@@ -762,7 +762,7 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.body.prompt_cache_key).toBeUndefined();
|
||||
expect(request.body.instructions).toBe('');
|
||||
expect(request.body.stream).toBe(false);
|
||||
expect(request.body.store).toBe(true);
|
||||
expect(request.body.store).toBe(false);
|
||||
expect(request.body.parallel_tool_calls).toBe(false);
|
||||
expect(request.body.include).toEqual(['reasoning.encrypted_content', 'mcp_approval_request.details']);
|
||||
expect(request.body.previous_response_id).toBe('resp_prev_123');
|
||||
@@ -848,7 +848,7 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(httpRequest.headers['x-codex-beta-features']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('applies configured payload rules before preparing codex responses requests', () => {
|
||||
it('applies configured payload rules before preparing codex responses requests while forcing store false', () => {
|
||||
(config as any).payloadRules = {
|
||||
default: [
|
||||
{
|
||||
@@ -864,6 +864,7 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
models: [{ name: 'gpt-5.4', protocol: 'codex' }],
|
||||
params: {
|
||||
'text.verbosity': 'low',
|
||||
store: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -899,6 +900,7 @@ describe('buildUpstreamEndpointRequest', () => {
|
||||
expect(request.body.reasoning).toEqual({ effort: 'high' });
|
||||
expect(request.body.text).toEqual({ verbosity: 'low' });
|
||||
expect(request.body.safety_identifier).toBeUndefined();
|
||||
expect(request.body.store).toBe(false);
|
||||
});
|
||||
|
||||
it('builds gemini-cli native requests with project envelope and bearer headers', () => {
|
||||
|
||||
@@ -413,7 +413,11 @@ function ensureCodexResponsesStoreFalse(
|
||||
body: Record<string, unknown>,
|
||||
sitePlatform: string,
|
||||
): Record<string, unknown> {
|
||||
return body;
|
||||
if (sitePlatform !== 'codex') return body;
|
||||
return {
|
||||
...body,
|
||||
store: false,
|
||||
};
|
||||
}
|
||||
|
||||
function convertCodexSystemRoleToDeveloper(input: unknown): unknown {
|
||||
@@ -1256,7 +1260,10 @@ export function buildUpstreamEndpointRequest(input: {
|
||||
),
|
||||
sitePlatform,
|
||||
);
|
||||
const configuredResponsesBody = applyConfiguredPayloadRules(body);
|
||||
const configuredResponsesBody = ensureCodexResponsesStoreFalse(
|
||||
applyConfiguredPayloadRules(body),
|
||||
sitePlatform,
|
||||
);
|
||||
|
||||
if (sitePlatform === 'codex') {
|
||||
if (providerProfile?.id !== 'codex') {
|
||||
|
||||
@@ -437,6 +437,67 @@ describe('sanitizeResponsesBodyForProxy', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops blank tool calls before proxying Responses requests while preserving follow-up tool outputs', () => {
|
||||
const result = sanitizeResponsesBodyForProxy(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'function_call',
|
||||
call_id: 'call_blank',
|
||||
name: ' ',
|
||||
arguments: '{"city":"Shanghai"}',
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_blank',
|
||||
output: '{"temp":22}',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_missing',
|
||||
output: 'orphaned output',
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.input).toEqual([
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_blank',
|
||||
output: '{"temp":22}',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_missing',
|
||||
output: 'orphaned output',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
@@ -763,6 +824,45 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops tool outputs that do not have a matching prior tool call when converting to OpenAI-compatible messages', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: [
|
||||
{
|
||||
type: 'function_call_output',
|
||||
call_id: 'call_missing',
|
||||
output: 'orphaned tool result',
|
||||
},
|
||||
{
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'output_text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.messages).toEqual([
|
||||
{
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: 'continue normally',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('shortens long MCP tool names consistently across tools, tool_choice and assistant tool calls', () => {
|
||||
const sharedSuffix = 'server__execute_super_long_nested_tool_name_that_needs_shortening';
|
||||
const firstName = `mcp__alpha_workspace__${sharedSuffix}`;
|
||||
@@ -840,6 +940,63 @@ describe('convertOpenAiBodyToResponsesBody', () => {
|
||||
name: toolNames[0],
|
||||
});
|
||||
});
|
||||
|
||||
it('drops function tools with blank names when converting OpenAI-compatible input into Responses bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: ' ',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'lookup_weather',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([
|
||||
{
|
||||
type: 'function',
|
||||
name: 'lookup_weather',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops all-invalid function tools instead of forwarding blank names into Responses bodies', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
messages: [{ role: 'user', content: 'hello' }],
|
||||
tools: [
|
||||
{
|
||||
type: 'function',
|
||||
function: {
|
||||
name: ' ',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
@@ -881,6 +1038,7 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
file: {
|
||||
filename: 'paper.pdf',
|
||||
file_data: Buffer.from('%PDF-demo').toString('base64'),
|
||||
mime_type: 'application/pdf',
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -1461,6 +1619,31 @@ describe('convertResponsesBodyToOpenAiBody', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('drops blank native function tools and tool choices when converting to OpenAI-compatible fallback bodies', () => {
|
||||
const result = convertResponsesBodyToOpenAiBody(
|
||||
{
|
||||
model: 'gpt-5',
|
||||
input: 'hello',
|
||||
tools: [{
|
||||
type: 'function',
|
||||
name: ' ',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
},
|
||||
}],
|
||||
tool_choice: {
|
||||
type: 'tool',
|
||||
name: ' ',
|
||||
},
|
||||
},
|
||||
'gpt-5',
|
||||
false,
|
||||
);
|
||||
|
||||
expect(result.tools).toEqual([]);
|
||||
expect(result.tool_choice).toBeUndefined();
|
||||
});
|
||||
|
||||
it('derives Responses reasoning config from chat-style reasoning request fields', () => {
|
||||
const result = convertOpenAiBodyToResponsesBody(
|
||||
{
|
||||
|
||||
@@ -352,7 +352,7 @@ function convertOpenAiToolsToResponses(
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
|
||||
return converted.length > 0 ? converted : rawTools;
|
||||
return converted;
|
||||
}
|
||||
|
||||
function convertOpenAiToolChoiceToResponses(
|
||||
@@ -384,6 +384,49 @@ function normalizeResponsesBodyForCompatibility(
|
||||
};
|
||||
}
|
||||
|
||||
const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set([
|
||||
'function_call',
|
||||
'custom_tool_call',
|
||||
]);
|
||||
|
||||
const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set([
|
||||
'function_call_output',
|
||||
'custom_tool_call_output',
|
||||
]);
|
||||
|
||||
function stripOrphanedResponsesToolOutputs(input: unknown): unknown {
|
||||
if (!Array.isArray(input)) return input;
|
||||
|
||||
const seenToolCallIds = new Set<string>();
|
||||
const sanitized: unknown[] = [];
|
||||
|
||||
for (const item of input) {
|
||||
if (!isRecord(item)) {
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) {
|
||||
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||
if (callId) seenToolCallIds.add(callId);
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) {
|
||||
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||
if (!callId || !seenToolCallIds.has(callId)) continue;
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized.push(item);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageContent(role: string, content: unknown): Array<Record<string, unknown>> {
|
||||
return normalizeResponsesMessageContentBlocks(role, content);
|
||||
}
|
||||
@@ -614,6 +657,9 @@ export function convertOpenAiBodyToResponsesBody(
|
||||
|
||||
const responsesToolChoice = convertOpenAiToolChoiceToResponses(openaiBody.tool_choice, toolNameMap);
|
||||
if (responsesToolChoice !== undefined) body.tool_choice = responsesToolChoice;
|
||||
if (Array.isArray(body.tools) && body.tools.length === 0) {
|
||||
delete body.tool_choice;
|
||||
}
|
||||
|
||||
return normalizeResponsesBodyForCompatibility(
|
||||
normalizeResponsesRequestFieldParity(body, { verbositySource: openaiBody.verbosity }),
|
||||
@@ -723,28 +769,30 @@ function toOpenAiMessageContent(content: unknown): string | Array<string | Recor
|
||||
function convertResponsesToolsToOpenAi(rawTools: unknown): unknown {
|
||||
if (!Array.isArray(rawTools)) return rawTools;
|
||||
|
||||
return rawTools.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
return rawTools
|
||||
.map((item) => {
|
||||
if (!isRecord(item)) return item;
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
|
||||
if (type === 'custom' || type === 'image_generation') return item;
|
||||
if (type !== 'function') return item;
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
|
||||
if (type === 'custom' || type === 'image_generation') return item;
|
||||
if (type !== 'function') return item;
|
||||
if (isRecord(item.function) && asTrimmedString(item.function.name)) return item;
|
||||
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return item;
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
const fn: Record<string, unknown> = { name };
|
||||
const description = asTrimmedString(item.description);
|
||||
if (description) fn.description = description;
|
||||
if (item.parameters !== undefined) fn.parameters = item.parameters;
|
||||
if (item.strict !== undefined) fn.strict = item.strict;
|
||||
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
});
|
||||
return {
|
||||
type: 'function',
|
||||
function: fn,
|
||||
};
|
||||
})
|
||||
.filter((item): item is Record<string, unknown> => !!item);
|
||||
}
|
||||
|
||||
function convertResponsesToolChoiceToOpenAi(rawToolChoice: unknown): unknown {
|
||||
@@ -787,11 +835,13 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
stream: boolean,
|
||||
options?: { defaultEncryptedReasoningInclude?: boolean },
|
||||
): Record<string, unknown> {
|
||||
const normalizedBody = normalizeResponsesRequestFieldParity(body, {
|
||||
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
|
||||
});
|
||||
const normalizedBody = normalizeResponsesBodyForCompatibility(
|
||||
normalizeResponsesRequestFieldParity(body, {
|
||||
defaultEncryptedReasoningInclude: options?.defaultEncryptedReasoningInclude,
|
||||
}),
|
||||
);
|
||||
const messages: Array<Record<string, unknown>> = [];
|
||||
const input = normalizedBody.input;
|
||||
const input = stripOrphanedResponsesToolOutputs(normalizedBody.input);
|
||||
let functionCallIndex = 0;
|
||||
let pendingToolCalls: OpenAiToolCall[] = [];
|
||||
|
||||
@@ -928,6 +978,9 @@ export function convertResponsesBodyToOpenAiBody(
|
||||
if (normalizedBody.parallel_tool_calls !== undefined) payload.parallel_tool_calls = normalizedBody.parallel_tool_calls;
|
||||
if (normalizedBody.tools !== undefined) payload.tools = convertResponsesToolsToOpenAi(normalizedBody.tools);
|
||||
if (normalizedBody.tool_choice !== undefined) payload.tool_choice = convertResponsesToolChoiceToOpenAi(normalizedBody.tool_choice);
|
||||
if (Array.isArray(payload.tools) && payload.tools.length === 0) {
|
||||
delete payload.tool_choice;
|
||||
}
|
||||
if (normalizedBody.safety_identifier !== undefined) payload.safety_identifier = normalizedBody.safety_identifier;
|
||||
if (normalizedBody.max_tool_calls !== undefined) payload.max_tool_calls = normalizedBody.max_tool_calls;
|
||||
if (normalizedBody.prompt_cache_key !== undefined) payload.prompt_cache_key = normalizedBody.prompt_cache_key;
|
||||
|
||||
@@ -8,6 +8,16 @@ function asTrimmedString(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
const RESPONSES_TOOL_CALL_ITEM_TYPES = new Set([
|
||||
'function_call',
|
||||
'custom_tool_call',
|
||||
]);
|
||||
|
||||
const RESPONSES_TOOL_OUTPUT_ITEM_TYPES = new Set([
|
||||
'function_call_output',
|
||||
'custom_tool_call_output',
|
||||
]);
|
||||
|
||||
const ALLOWED_RESPONSES_INPUT_STATUSES = new Set([
|
||||
'in_progress',
|
||||
'completed',
|
||||
@@ -135,7 +145,7 @@ function normalizeResponsesContentItem(
|
||||
return fileBlock ? toResponsesInputFileBlock(fileBlock) : null;
|
||||
}
|
||||
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -166,10 +176,71 @@ function toResponsesInputMessageFromText(text: string): Record<string, unknown>
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeResponsesToolLifecycleItem(item: Record<string, unknown>): Record<string, unknown> | null {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (!RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) && !RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
return withNormalizedResponsesInputStatus(item);
|
||||
}
|
||||
|
||||
const callId = asTrimmedString(item.call_id ?? item.id);
|
||||
if (!callId) return null;
|
||||
|
||||
const normalized: Record<string, unknown> = {
|
||||
...item,
|
||||
call_id: callId,
|
||||
};
|
||||
|
||||
if (typeof item.id === 'string') {
|
||||
const id = asTrimmedString(item.id);
|
||||
if (id) normalized.id = id;
|
||||
else delete normalized.id;
|
||||
}
|
||||
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, 'name')) {
|
||||
const name = asTrimmedString(item.name);
|
||||
if (!name) return null;
|
||||
normalized.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
return withNormalizedResponsesInputStatus(normalized);
|
||||
}
|
||||
|
||||
function sanitizeResponsesInputToolLifecycle(items: unknown[]): unknown[] {
|
||||
const sanitized: unknown[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
if (!isRecord(item)) {
|
||||
sanitized.push(item);
|
||||
continue;
|
||||
}
|
||||
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type)) {
|
||||
const normalized = normalizeResponsesToolLifecycleItem(item);
|
||||
if (!normalized) continue;
|
||||
sanitized.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
const normalized = normalizeResponsesToolLifecycleItem(item);
|
||||
if (!normalized) continue;
|
||||
sanitized.push(normalized);
|
||||
continue;
|
||||
}
|
||||
|
||||
sanitized.push(item);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
export function normalizeResponsesMessageItem(item: Record<string, unknown>): Record<string, unknown> {
|
||||
const type = asTrimmedString(item.type).toLowerCase();
|
||||
if (type === 'function_call' || type === 'function_call_output') {
|
||||
return withNormalizedResponsesInputStatus(item);
|
||||
if (RESPONSES_TOOL_CALL_ITEM_TYPES.has(type) || RESPONSES_TOOL_OUTPUT_ITEM_TYPES.has(type)) {
|
||||
return normalizeResponsesToolLifecycleItem(item) ?? item;
|
||||
}
|
||||
|
||||
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
||||
@@ -211,7 +282,7 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
return input.flatMap((item) => {
|
||||
const normalized = input.flatMap((item) => {
|
||||
if (typeof item === 'string') {
|
||||
const normalized = item.trim();
|
||||
return normalized ? [toResponsesInputMessageFromText(normalized)] : [];
|
||||
@@ -219,10 +290,11 @@ export function normalizeResponsesInputForCompatibility(input: unknown): unknown
|
||||
if (!isRecord(item)) return [item];
|
||||
return [normalizeResponsesMessageItem(item)];
|
||||
});
|
||||
return sanitizeResponsesInputToolLifecycle(normalized);
|
||||
}
|
||||
|
||||
if (isRecord(input)) {
|
||||
return [normalizeResponsesMessageItem(input)];
|
||||
return sanitizeResponsesInputToolLifecycle([normalizeResponsesMessageItem(input)]);
|
||||
}
|
||||
|
||||
return input;
|
||||
|
||||
Reference in New Issue
Block a user