From 715bc52faf6a51c73198b67f25c33cb18a2d2fcf Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:33:03 +0800 Subject: [PATCH 1/2] fix(provider-codex): drop invalid local message ids --- .../src/interceptors/responses/index.ts | 8 +- .../responses/sanitize-message-ids.ts | 29 +++++ .../responses/sanitize-message-ids_test.ts | 105 ++++++++++++++++++ 3 files changed, 139 insertions(+), 3 deletions(-) create mode 100644 packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts create mode 100644 packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts diff --git a/packages/provider-codex/src/interceptors/responses/index.ts b/packages/provider-codex/src/interceptors/responses/index.ts index 63150890b..207c34321 100644 --- a/packages/provider-codex/src/interceptors/responses/index.ts +++ b/packages/provider-codex/src/interceptors/responses/index.ts @@ -4,14 +4,15 @@ import { injectDefaultInstructions } from './inject-default-instructions.ts'; import { injectSessionId } from './inject-session-id.ts'; +import { sanitizeMessageIds } from './sanitize-message-ids.ts'; import { stripUnsupportedFields } from './strip-unsupported-fields.ts'; import type { ResponsesBoundaryCtx } from './types.ts'; import type { Interceptor } from '@floway-dev/interceptor'; -// Order rationale: none of the three interceptors below read or write a field -// the others touch, so order is positional only. inject-session-id last is +// Order rationale: none of the interceptors below read or write a field the +// others touch, so order is positional only. inject-session-id last is // conventional but not load-bearing — it hashes only `instructions + first -// user-message text`, neither of which is mutated by the other two. +// user-message text`, neither of which is mutated by the earlier mutators. // // Each interceptor is generic over the terminal result type: the streaming // `/responses` chain runs to ProviderStreamResult, the compaction chain runs @@ -21,5 +22,6 @@ import type { Interceptor } from '@floway-dev/interceptor'; export const codexResponsesChain = (): readonly Interceptor[] => [ injectDefaultInstructions, stripUnsupportedFields, + sanitizeMessageIds, injectSessionId, ]; diff --git a/packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts new file mode 100644 index 000000000..082f128e0 --- /dev/null +++ b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts @@ -0,0 +1,29 @@ +import type { ResponsesBoundaryCtx } from './types.ts'; +import type { ResponsesInputItem } from '@floway-dev/protocols/responses'; + +export const sanitizeMessageIds = async ( + ctx: ResponsesBoundaryCtx, + _request: object, + run: () => Promise, +): Promise => { + if (!Array.isArray(ctx.payload.input)) return await run(); + + const input = sanitizeInputItems(ctx.payload.input); + if (input !== ctx.payload.input) ctx.payload = { ...ctx.payload, input }; + return await run(); +}; + +const sanitizeInputItems = (items: ResponsesInputItem[]): ResponsesInputItem[] => { + let changed = false; + const sanitized = items.map(item => { + if (item.type !== 'message') return item; + const id = (item as { id?: unknown }).id; + if (id === undefined || (typeof id === 'string' && id.startsWith('msg'))) return item; + + const next: Record = { ...item }; + delete next.id; + changed = true; + return next as unknown as ResponsesInputItem; + }); + return changed ? sanitized : items; +}; diff --git a/packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts new file mode 100644 index 000000000..b43c55dbf --- /dev/null +++ b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts @@ -0,0 +1,105 @@ +import { test } from 'vitest'; + +import { sanitizeMessageIds } from './sanitize-message-ids.ts'; +import type { ResponsesBoundaryCtx } from './types.ts'; +import type { ResponsesPayload, ResponsesStreamEvent } from '@floway-dev/protocols/responses'; +import type { ProviderStreamResult } from '@floway-dev/provider'; +import { assertEquals, stubUpstreamModel } from '@floway-dev/test-utils'; + +const stubRequest = {}; + +const okEvents = (): Promise> => + Promise.resolve({ ok: true, events: (async function* () {})(), modelKey: 'test', headers: new Headers() }); + +const invocation = (payload: ResponsesPayload): ResponsesBoundaryCtx => ({ + payload, + headers: new Headers(), + model: stubUpstreamModel({ endpoints: { responses: {} } }), +}); + +test('drops Codex review rollout message ids while preserving review content', async () => { + const ctx = invocation({ + model: 'gpt-test', + input: [ + { + type: 'message', + id: 'review_rollout_user', + role: 'user', + content: [{ type: 'input_text', text: 'User initiated a review task.' }], + }, + { + type: 'message', + id: 'review_rollout_assistant', + role: 'assistant', + content: [{ type: 'output_text', text: 'review assistant output' }], + }, + ], + }); + + await sanitizeMessageIds(ctx, stubRequest, okEvents); + + assertEquals(ctx.payload.input, [ + { + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: 'User initiated a review task.' }], + }, + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'review assistant output' }], + }, + ]); +}); + +test('keeps Codex/OpenAI message ids intact', async () => { + const ctx = invocation({ + model: 'gpt-test', + input: [ + { type: 'message', id: 'msg_valid', role: 'user', content: 'hello' }, + ], + }); + + await sanitizeMessageIds(ctx, stubRequest, okEvents); + + assertEquals(ctx.payload.input, [ + { type: 'message', id: 'msg_valid', role: 'user', content: 'hello' }, + ]); +}); + +test('keeps non-message item ids intact', async () => { + const ctx = invocation({ + model: 'gpt-test', + input: [ + { + type: 'function_call', + id: 'call_local', + call_id: 'call_1', + name: 'tool', + arguments: '{}', + status: 'completed', + }, + ], + }); + + await sanitizeMessageIds(ctx, stubRequest, okEvents); + + assertEquals(ctx.payload.input, [ + { + type: 'function_call', + id: 'call_local', + call_id: 'call_1', + name: 'tool', + arguments: '{}', + status: 'completed', + }, + ]); +}); + +test('leaves string input untouched', async () => { + const ctx = invocation({ model: 'gpt-test', input: 'hello' }); + + await sanitizeMessageIds(ctx, stubRequest, okEvents); + + assertEquals(ctx.payload, { model: 'gpt-test', input: 'hello' }); +}); From 3eb01f3f73c31fe853ab2ca4adf09e9dc7d9f4d7 Mon Sep 17 00:00:00 2001 From: Kagurazaka Mikoto <58372301+M1k0t0@users.noreply.github.com> Date: Tue, 23 Jun 2026 19:57:59 +0800 Subject: [PATCH 2/2] fix(provider-codex): sanitize typeless response message ids --- .../responses/sanitize-message-ids.ts | 10 ++++++++- .../responses/sanitize-message-ids_test.ts | 22 +++++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts index 082f128e0..36162c7c6 100644 --- a/packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts +++ b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids.ts @@ -16,7 +16,7 @@ export const sanitizeMessageIds = async ( const sanitizeInputItems = (items: ResponsesInputItem[]): ResponsesInputItem[] => { let changed = false; const sanitized = items.map(item => { - if (item.type !== 'message') return item; + if (!isMessageInputItem(item)) return item; const id = (item as { id?: unknown }).id; if (id === undefined || (typeof id === 'string' && id.startsWith('msg'))) return item; @@ -27,3 +27,11 @@ const sanitizeInputItems = (items: ResponsesInputItem[]): ResponsesInputItem[] = }); return changed ? sanitized : items; }; + +const isMessageInputItem = (item: ResponsesInputItem): boolean => { + const obj = item as { type?: unknown; role?: unknown }; + return obj.type === undefined ? isMessageRole(obj.role) : obj.type === 'message'; +}; + +const isMessageRole = (role: unknown): boolean => + role === 'user' || role === 'assistant' || role === 'system' || role === 'developer'; diff --git a/packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts index b43c55dbf..bc4908b91 100644 --- a/packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts +++ b/packages/provider-codex/src/interceptors/responses/sanitize-message-ids_test.ts @@ -52,6 +52,28 @@ test('drops Codex review rollout message ids while preserving review content', a ]); }); +test('drops invalid ids from typeless easy input messages', async () => { + const ctx = invocation({ + model: 'gpt-test', + input: [ + { + id: 'review_rollout_user', + role: 'user', + content: 'User initiated a review task.', + }, + ], + } as unknown as ResponsesPayload); + + await sanitizeMessageIds(ctx, stubRequest, okEvents); + + assertEquals(ctx.payload.input, [ + { + role: 'user', + content: 'User initiated a review task.', + }, + ]); +}); + test('keeps Codex/OpenAI message ids intact', async () => { const ctx = invocation({ model: 'gpt-test',