mirror of
https://github.com/enricoros/big-AGI.git
synced 2026-05-10 21:50:14 -07:00
AIX: Part xAI vs. OpenAI encrypted reasoning
This commit is contained in:
@@ -103,7 +103,13 @@ export type DMessageFragmentVendorState = Record<string, unknown> & {
|
||||
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?: { ... }
|
||||
|
||||
@@ -834,11 +834,11 @@ export class ContentReassembler {
|
||||
|
||||
}
|
||||
|
||||
private onSetVendorState(vs: Extract<AixWire_Particles.PartParticleOp, { p: 'svs' }>): void {
|
||||
private onSetVendorState({ state, vendor }: Extract<AixWire_Particles.PartParticleOp, { p: 'svs' }>): 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> } // disable catch-all becasue it forces casts in type discriminations
|
||||
)
|
||||
;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user