From a43b6a2cf5a7b56eb1dac0d38092ca507202c2fa Mon Sep 17 00:00:00 2001 From: Enrico Ros Date: Tue, 28 Apr 2026 09:22:31 -0700 Subject: [PATCH] AIX: Part xAI vs. OpenAI encrypted reasoning --- src/common/stores/chat/chat.fragments.ts | 8 ++- src/modules/aix/client/ContentReassembler.ts | 15 +++--- src/modules/aix/server/api/aix.wiretypes.ts | 10 ++++ .../adapters/openai.responsesCreate.ts | 11 ++-- .../adapters/xai.responsesCreate.ts | 15 +++--- .../chatGenerate/chatGenerate.dispatch.ts | 16 ++++-- .../parsers/openai.responses.parser.ts | 50 ++++++++++++------- 7 files changed, 85 insertions(+), 40 deletions(-) diff --git a/src/common/stores/chat/chat.fragments.ts b/src/common/stores/chat/chat.fragments.ts index 78b0d2824..8766cd319 100644 --- a/src/common/stores/chat/chat.fragments.ts +++ b/src/common/stores/chat/chat.fragments.ts @@ -103,7 +103,13 @@ export type DMessageFragmentVendorState = Record & { thoughtSignature?: string; // Gemini 3+ - echoed back to maintain reasoning context }; openai?: { - // Responses API reasoning item continuity handle + // Responses API reasoning item continuity handle. + // IMPORTANT: OpenAI-private encryption + server-side item id; never round-trip to xAI. + reasoningItem?: { id?: string; encryptedContent?: string; }; + }; + xai?: { + // xAI Responses API reasoning item continuity handle. + // IMPORTANT: xAI-private encryption + server-side item id; never round-trip to OpenAI. reasoningItem?: { id?: string; encryptedContent?: string; }; }; // Future: anthropic?: { ... } diff --git a/src/modules/aix/client/ContentReassembler.ts b/src/modules/aix/client/ContentReassembler.ts index b2a62556e..9293ea77a 100644 --- a/src/modules/aix/client/ContentReassembler.ts +++ b/src/modules/aix/client/ContentReassembler.ts @@ -834,11 +834,11 @@ export class ContentReassembler { } - private onSetVendorState(vs: Extract): void { + private onSetVendorState({ state, vendor }: Extract): void { // Promote Anthropic container state -> Generator (message-scoped, for cross-turn reuse) - if (vs.vendor === 'anthropic' && 'container' in vs.state) { - const { id, expiresAt } = vs.state.container; + if (vendor === 'anthropic' && 'container' in state) { + const { id, expiresAt } = state.container; if (id && expiresAt) this.S.generator = { ...this.S.generator, @@ -855,11 +855,12 @@ export class ContentReassembler { return; } - // Guard: OpenAI reasoningItem state must land on the ma (reasoning) fragment that produced it. + // Guard: reasoningItem state must land on the ma (reasoning) fragment that produced it. // If no summary was appended during the reasoning item (summary disabled / skipped), the last // fragment will belong to an unrelated preceding item - dropping the handle is safer than contaminating. - if (vs.vendor === 'openai' && 'reasoningItem' in vs.state && lastFragment.part.pt !== 'ma') { - console.warn('[ContentReassembler] OpenAI reasoningItem state without preceding ma fragment - dropping continuity handle', { lastFragmentPt: lastFragment.part.pt }); + // Applies to both OpenAI and xAI namespaces; each is opaque/private to its producing vendor. + if ((vendor === 'openai' || vendor === 'xai') && 'reasoningItem' in state && lastFragment.part.pt !== 'ma') { + console.warn(`[ContentReassembler] ${vendor} reasoningItem state without preceding ma fragment - dropping continuity handle`, { lastFragmentPt: lastFragment.part.pt }); return; } @@ -868,7 +869,7 @@ export class ContentReassembler { ...lastFragment, vendorState: { ...lastFragment.vendorState, - [vs.vendor]: vs.state, + [vendor]: state, }, }); } diff --git a/src/modules/aix/server/api/aix.wiretypes.ts b/src/modules/aix/server/api/aix.wiretypes.ts index 8ce35af14..e6b67a593 100644 --- a/src/modules/aix/server/api/aix.wiretypes.ts +++ b/src/modules/aix/server/api/aix.wiretypes.ts @@ -104,11 +104,20 @@ export namespace AixWire_Parts { openai: z.object({ // Responses API reasoning item continuity handle. Sub-object mirrors the shape of the source output item // and parallels _vnd Anthropic's { container: { id, expiresAt } } pattern. + // IMPORTANT: this blob is OpenAI-server-encrypted; do NOT round-trip to xAI (different keys + private item ids). reasoningItem: z.object({ id: z.string().optional(), // rs_... - item id encryptedContent: z.string().optional(), // blob returned when include:['reasoning.encrypted_content'] }).optional(), }).optional(), + xai: z.object({ + // xAI Responses API reasoning item continuity handle. Same WIRE shape as OpenAI's, but the encrypted_content + // is encrypted with xAI's keys and the item id references xAI server state - NOT cross-portable to OpenAI. + reasoningItem: z.object({ + id: z.string().optional(), + encryptedContent: z.string().optional(), + }).optional(), + }).optional(), // NOTE: we do NOT use this mechanism for per-vendor customization/ALT for parts // anthropic: z.object({ // containerUpload: z.object({ @@ -786,6 +795,7 @@ export namespace AixWire_Particles { | { vendor: 'anthropic', state: { container: { id: string; expiresAt: string } } } // message-level | { vendor: 'gemini', state: { thoughtSignature: string } } // fragment-level | { vendor: 'openai', state: { reasoningItem: { id?: string, encryptedContent?: string } } } // fragment-level (attach to ma reasoning fragment) + | { vendor: 'xai', state: { reasoningItem: { id?: string, encryptedContent?: string } } } // fragment-level - DISTINCT from openai (different encryption keys, different server-side ids) // | { vendor: string, state: Record } // disable catch-all becasue it forces casts in type discriminations ) ; diff --git a/src/modules/aix/server/dispatch/chatGenerate/adapters/openai.responsesCreate.ts b/src/modules/aix/server/dispatch/chatGenerate/adapters/openai.responsesCreate.ts index 64aa551b2..806c9d0a3 100644 --- a/src/modules/aix/server/dispatch/chatGenerate/adapters/openai.responsesCreate.ts +++ b/src/modules/aix/server/dispatch/chatGenerate/adapters/openai.responsesCreate.ts @@ -495,12 +495,15 @@ function _toOpenAIResponsesRequestInput(systemMessage: AixMessages_SystemMessage case 'ma': // Preserve reasoning continuity across turns via _vnd.openai.reasoningItem (set by openai.responses.parser). - // Stateless (store=false, our default): encryptedContent is the protocol-critical blob for the provider to reconstruct internal reasoning state. + // Round-trip ONLY when both encrypted_content AND id are present (canonical, complete handle). + // - bare id without EC -> 404 "Item with id rs_... not found" in stateless mode + // - bare EC without id -> torn handle, undefined behavior across providers/versions + // Defense-in-depth: matches the parser's capture gate; rejects torn handles even if any sneak through. + // ma fragments without an openai handle are common (e.g., DeepSeek reasoning_content emits ma fragments + // with no continuity blob) - skip without warning to avoid log noise on cross-vendor history. const oaiReasoning = modelPart._vnd?.openai?.reasoningItem; - if (oaiReasoning?.encryptedContent || oaiReasoning?.id) + if (oaiReasoning?.encryptedContent && oaiReasoning?.id) newReasoningMessage(oaiReasoning.id, oaiReasoning.encryptedContent); - else - console.warn('[DEV] OpenAI Responses: skipping reasoning item due to missing encrypted content and id', { modelPart }); break; case 'tool_response': diff --git a/src/modules/aix/server/dispatch/chatGenerate/adapters/xai.responsesCreate.ts b/src/modules/aix/server/dispatch/chatGenerate/adapters/xai.responsesCreate.ts index 19d628acc..5825262df 100644 --- a/src/modules/aix/server/dispatch/chatGenerate/adapters/xai.responsesCreate.ts +++ b/src/modules/aix/server/dispatch/chatGenerate/adapters/xai.responsesCreate.ts @@ -329,12 +329,15 @@ function _toXAIResponsesInput( break; case 'ma': - // xAI reuses the OpenAI Responses continuity namespace (_vnd.openai.reasoningItem). - // Only active when AIX_XAI_ADD_ENCRYPTED_REASONING is enabled and encrypted_content is captured; - // otherwise the handle is absent and we skip to avoid "Item with id rs_... not found" style errors. - const oaiReasoning = part._vnd?.openai?.reasoningItem; - if (oaiReasoning?.encryptedContent || oaiReasoning?.id) - newReasoningItem(oaiReasoning.id, oaiReasoning.encryptedContent); + // xAI uses its OWN _vnd namespace - the wire schema mirrors OpenAI's, but encrypted_content is + // encrypted with xAI-private keys and the rs_... id references xAI-private server state. Crossing + // these (e.g., replaying an OpenAI handle to xAI or vice versa) yields "Item with id rs_... not + // found" or silent reasoning corruption. + // Round-trip ONLY when both encrypted_content AND id are present (canonical, complete handle). + // Defense-in-depth: matches the parser's capture gate; rejects torn handles even if any sneak through. + const xaiReasoning = part._vnd?.xai?.reasoningItem; + if (xaiReasoning?.encryptedContent && xaiReasoning?.id) + newReasoningItem(xaiReasoning.id, xaiReasoning.encryptedContent); break; case 'tool_response': diff --git a/src/modules/aix/server/dispatch/chatGenerate/chatGenerate.dispatch.ts b/src/modules/aix/server/dispatch/chatGenerate/chatGenerate.dispatch.ts index fd592de4f..6532b5749 100644 --- a/src/modules/aix/server/dispatch/chatGenerate/chatGenerate.dispatch.ts +++ b/src/modules/aix/server/dispatch/chatGenerate/chatGenerate.dispatch.ts @@ -247,9 +247,9 @@ export async function createChatGenerateDispatch(access: AixAPI_Access, model: A case 'zai': // newer: OpenAI Responses API, for models that support it and all XAI models - const isResponsesAPI = !!model.vndOaiResponsesAPI; const isXAIModel = dialect === 'xai'; // All XAI models are accessed via Responses now - if (isResponsesAPI || isXAIModel) { + const isResponsesAPI = !!model.vndOaiResponsesAPI || isXAIModel; + if (isResponsesAPI) { return { request: { ...openAIAccess(access, model.id, OPENAI_API_PATHS.responses), @@ -264,11 +264,17 @@ export async function createChatGenerateDispatch(access: AixAPI_Access, model: A * * Note: Response format is compatible with OpenAI parser. */ - body: isXAIModel ? aixToXAIResponses(model, chatGenerate, streaming, enableResumability) + body: isXAIModel + ? aixToXAIResponses(model, chatGenerate, streaming, enableResumability) : aixToOpenAIResponses(dialect, model, chatGenerate, streaming, enableResumability), }, demuxerFormat: streaming ? 'fast-sse' : null, - chatGenerateParse: streaming ? createOpenAIResponsesEventParser() : createOpenAIResponseParserNS(), + // IMPORTANT: tag the parser with the actual vendor so reasoning continuity blobs + // (encrypted_content + rs_... id) land in the matching _vnd namespace and never leak + // across providers (different keys + different server-side state). + chatGenerateParse: streaming + ? createOpenAIResponsesEventParser(isXAIModel ? 'xai' : 'openai') + : createOpenAIResponseParserNS(isXAIModel ? 'xai' : 'openai'), }; } @@ -319,7 +325,7 @@ export async function createChatGenerateResumeDispatch(access: AixAPI_Access, re return { request: { url: `${url}?${queryParams.toString()}`, method: 'GET', headers }, demuxerFormat: streaming ? 'fast-sse' : null, - chatGenerateParse: streaming ? createOpenAIResponsesEventParser() : createOpenAIResponseParserNS(), + chatGenerateParse: streaming ? createOpenAIResponsesEventParser('openai') : createOpenAIResponseParserNS('openai'), }; case 'gemini': { diff --git a/src/modules/aix/server/dispatch/chatGenerate/parsers/openai.responses.parser.ts b/src/modules/aix/server/dispatch/chatGenerate/parsers/openai.responses.parser.ts index 4b6dfcb97..87ead98a1 100644 --- a/src/modules/aix/server/dispatch/chatGenerate/parsers/openai.responses.parser.ts +++ b/src/modules/aix/server/dispatch/chatGenerate/parsers/openai.responses.parser.ts @@ -280,8 +280,13 @@ class ResponseParserStateMachine { /** * OpenAI Responses API Streaming Parser + * + * @param vendor 'openai' (default) or 'xai' - tags the reasoning continuity handle so it round-trips back + * to the SAME provider. The OpenAI Responses wire format is shared with xAI, but the encrypted_content blob + * and the rs_... id are vendor-server-private (different keys, different state). Mixing them produces + * "Item with id rs_... not found" or worse silent corruption. */ -export function createOpenAIResponsesEventParser(): ChatGenerateParseFunction { +export function createOpenAIResponsesEventParser(vendor: 'openai' | 'xai'): ChatGenerateParseFunction { const R = new ResponseParserStateMachine(); @@ -425,22 +430,28 @@ export function createOpenAIResponsesEventParser(): ChatGenerateParseFunction { // NOTE: the authoritative encrypted_content arrives on .done (differs from the earlier .added event). const { id: reasoningId, encrypted_content: reasoningEC } = doneItem; - // [DEV] surface cases that diverge from our continuity round-trip expectations + // Capture ONLY when BOTH encrypted_content AND id are present (the canonical reasoning item shape). + // - id-only: refers to server state we don't keep in stateless mode (store: false, our default) -> 404 next turn + // - EC-only: a "torn" handle that breaks future stateful flows and possible id<->EC integrity checks + // - neither: nothing to round-trip + // [DEV] surface divergences from this contract if (!reasoningId && !reasoningEC) - console.warn('[DEV] AIX: OpenAI Responses: reasoning item done with neither id nor encrypted_content - no continuity handle captured for this turn', { doneItem }); + console.warn(`[DEV] AIX: ${vendor} Responses: reasoning item done with neither id nor encrypted_content - no continuity handle captured for this turn`, { doneItem }); else if (!reasoningEC) - console.log('[DEV] AIX: OpenAI Responses: reasoning item done has id but no encrypted_content - stateless round-trip requires include:[\'reasoning.encrypted_content\'] on the request'); + console.log(`[DEV] AIX: ${vendor} Responses: reasoning item done has id but no encrypted_content - dropping handle (stateless round-trip requires include:['reasoning.encrypted_content'] on the request)`); + else if (!reasoningId) + console.log(`[DEV] AIX: ${vendor} Responses: reasoning item done has encrypted_content but no id - dropping handle (incomplete reasoning item from upstream)`); - if (reasoningEC || reasoningId) { + if (reasoningEC && reasoningId) { // Defensive: ensure an ma fragment exists as the attach target for the svs particle below. pt.appendReasoningText(''); pt.sendSetVendorState({ p: 'svs', - vendor: 'openai', + vendor: vendor, state: { reasoningItem: { - ...(reasoningId ? { id: reasoningId } : {}), - ...(reasoningEC ? { encryptedContent: reasoningEC } : {}), + id: reasoningId, + encryptedContent: reasoningEC, }, }, }); @@ -760,8 +771,11 @@ export function createOpenAIResponsesEventParser(): ChatGenerateParseFunction { /** * OpenAI Responses API Non-Streaming Parser + * + * @param vendor 'openai' (default) or 'xai' - see createOpenAIResponsesEventParser for the rationale on + * why xAI gets its own _vnd namespace (different encryption keys + private item ids). */ -export function createOpenAIResponseParserNS(): ChatGenerateParseFunction { +export function createOpenAIResponseParserNS(vendor: 'openai' | 'xai'): ChatGenerateParseFunction { const parserCreationTimestamp = Date.now(); @@ -898,23 +912,25 @@ export function createOpenAIResponseParserNS(): ChatGenerateParseFunction { pt.appendReasoningText(item.text); } - // [DEV] surface cases that diverge from our continuity round-trip expectations + // [DEV] surface cases that diverge from our continuity round-trip expectations (see streaming path for rationale) if (!reasoningId && !reasoningEC) - console.warn('[DEV] AIX: OpenAI-Response-NS: reasoning item has neither id nor encrypted_content - no continuity handle captured for this turn', { oItem }); + console.warn(`[DEV] AIX: ${vendor}-Response-NS: reasoning item has neither id nor encrypted_content - no continuity handle captured for this turn`, { oItem }); else if (!reasoningEC) - console.log('[DEV] AIX: OpenAI-Response-NS: reasoning item has id but no encrypted_content - stateless round-trip requires include:[\'reasoning.encrypted_content\'] on the request'); + console.log(`[DEV] AIX: ${vendor}-Response-NS: reasoning item has id but no encrypted_content - dropping handle (stateless round-trip requires include:['reasoning.encrypted_content'] on the request)`); + else if (!reasoningId) + console.log(`[DEV] AIX: ${vendor}-Response-NS: reasoning item has encrypted_content but no id - dropping handle (incomplete reasoning item from upstream)`); - // Capture the continuity handle (encrypted_content + id) for stateless multi-turn round-tripping. - if (reasoningEC || reasoningId) { + // Capture ONLY when both id and encryptedContent are present (canonical, complete handle). + if (reasoningEC && reasoningId) { // Defensive: ensure an ma fragment exists as the attach target for the svs particle below (parity with the streaming path). pt.appendReasoningText(''); pt.sendSetVendorState({ p: 'svs', - vendor: 'openai', + vendor: vendor, state: { reasoningItem: { - ...(reasoningId ? { id: reasoningId } : {}), - ...(reasoningEC ? { encryptedContent: reasoningEC } : {}), + id: reasoningId, + encryptedContent: reasoningEC, }, }, });