Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 27 additions & 13 deletions src/adapters/openai-responses.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,10 @@ export const FORWARD_HEADERS = [

export function sanitizeReasoningInputContent(
body: unknown,
opts?: { preserveRawReasoningContent?: boolean },
opts?: {
preserveRawReasoningContent?: boolean;
stripEncryptedContent?: boolean;
},
): unknown {
if (!body || typeof body !== "object" || Array.isArray(body)) return body;
const raw = body as Record<string, unknown>;
Expand All @@ -56,24 +59,32 @@ export function sanitizeReasoningInputContent(
// ocxr1 envelopes are proxy-minted (Anthropic signatures), not OpenAI encryption — the native
// backend cannot decrypt them and would reject the request. Strip regardless of content shape.
const hasOcxEnvelope = typeof rec.encrypted_content === "string" && rec.encrypted_content.startsWith(OCX_REASONING_PREFIX);
if (!hasRawContent && !hasOcxEnvelope) return item;
if (hasOcxEnvelope) {
changed = true;
const next: Record<string, unknown> = { ...rec };
delete next.encrypted_content;
if (!opts?.preserveRawReasoningContent) next.content = [];
return next;
}
const hasOutputStatus = Object.prototype.hasOwnProperty.call(rec, "status");
const hasEncryptedContent = Object.prototype.hasOwnProperty.call(rec, "encrypted_content");
const stripEncryptedContent = hasOcxEnvelope
|| (opts?.stripEncryptedContent === true && hasEncryptedContent);
const retainsEncryptedContent = hasEncryptedContent && !stripEncryptedContent;
// Invariant for fields newly stripped by this cross-backend layer: an item whose
// encrypted_content is forwarded keeps status because OpenAI-operated backends bind opaque
// reasoning blobs to the item shape. Content blanking predates this invariant and remains
// required by ChatGPT's input contract; a native blob plus raw content is a known unresolved
// shape conflict, not an oversight to resolve by preserving content here.
const stripOutputStatus = hasOutputStatus && !retainsEncryptedContent;
const blankContent = !opts?.preserveRawReasoningContent && (hasRawContent || hasOcxEnvelope);
if (!blankContent && !stripOutputStatus && !stripEncryptedContent) return item;
changed = true;
const next: Record<string, unknown> = { ...rec };
if (stripOutputStatus) delete next.status;
if (stripEncryptedContent) delete next.encrypted_content;
// Routed models can produce raw `reasoning_text` output items. Codex echoes those in later
// native GPT requests, but ChatGPT's Responses backend accepts reasoning input only with empty
// `content`; keep summaries/ids and drop the raw content so native passthrough does not 400.
// DeepSeek's Responses API instead ACCEPTS plaintext reasoning replay (its compatibility
// guide merges reasoning items into the adjacent assistant message), so providers flagged
// `preserveResponsesReasoningContent` keep it — deleting valid replay content there breaks
// continuations after tool calls (issue #875 family).
if (opts?.preserveRawReasoningContent) return item;
changed = true;
return { ...rec, content: [] };
if (blankContent) next.content = [];
return next;
});

return changed ? { ...raw, input } : body;
Expand Down Expand Up @@ -1572,7 +1583,10 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
outBody = rewritten.body;
convertedRoutedToolSearchNames = rewritten.names;
}
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), { preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true })))))));
const sanitizedBody = normalizeToolSchemas(stripSparkCompatibility(stripUnsupportedReasoningParams(stripItemIdsWhenUnstored(stripInvalidItemIds(stripUnsupportedHostedTools(sanitizeReasoningInputContent(scrubOcxCompactionItems(outBody), {
preserveRawReasoningContent: provider.preserveResponsesReasoningContent === true,
stripEncryptedContent: parsed._stripReasoningEncryptedContent === true,
})))))));
const finalBody = stripDisabledReasoningSummaries(
normalizeConfiguredReasoningSummaryDelivery(sanitizedBody, provider, parsed.modelId),
provider,
Expand Down
114 changes: 107 additions & 7 deletions src/responses/reasoning-replay-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,16 @@ interface CacheEntry {
at: number;
}

interface ServingIdentityEntry {
identity: string;
bytes: number;
at: number;
}

const entries = new Map<string, CacheEntry>();
const servingIdentities = new Map<string, ServingIdentityEntry>();
let totalBytes = 0;
let servingIdentityTotalBytes = 0;
let clockForTests: (() => number) | null = null;

const now = (): number => clockForTests?.() ?? Date.now();
Expand All @@ -62,28 +70,118 @@ function nonEmpty(value: unknown): value is string {
return typeof value === "string" && value.trim().length > 0;
}

function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined {
const identity = scope?.current;
type ReasoningReplayIdentityTuple = readonly [string, string, string, string, string];

function tupleForIdentity(
identity: Readonly<OcxReasoningReplayIdentity> | undefined,
): ReasoningReplayIdentityTuple | undefined {
if (
!nonEmpty(callId)
|| !nonEmpty(scope?.clientThreadId)
|| !nonEmpty(identity?.providerName)
!nonEmpty(identity?.providerName)
|| !nonEmpty(identity?.providerDestinationIdentity)
|| !nonEmpty(identity?.adapterName)
|| !nonEmpty(identity?.modelId)
|| !nonEmpty(identity?.credentialIdentity)
) return undefined;
return JSON.stringify([
scope.clientThreadId,
return [
identity.providerName,
identity.providerDestinationIdentity,
identity.adapterName,
identity.modelId,
identity.credentialIdentity,
];
}

function tupleForServingIdentity(
identity: Readonly<OcxReasoningReplayIdentity> | undefined,
): ReasoningReplayIdentityTuple | undefined {
if (
!nonEmpty(identity?.providerName)
|| !nonEmpty(identity?.providerDestinationDurableIdentity)
|| !nonEmpty(identity?.adapterName)
|| !nonEmpty(identity?.modelId)
|| !nonEmpty(identity?.credentialDurableIdentity)
) return undefined;
return [
identity.providerName,
identity.providerDestinationDurableIdentity,
identity.adapterName,
identity.modelId,
identity.credentialDurableIdentity,
];
}

function keyFor(callId: string, scope: OcxReasoningReplayScopeRef | undefined): string | undefined {
const identity = tupleForIdentity(scope?.current);
if (!nonEmpty(callId) || !nonEmpty(scope?.clientThreadId) || !identity) return undefined;
return JSON.stringify([
scope.clientThreadId,
...identity,
callId,
]);
}

function deleteServingIdentity(threadId: string): void {
const entry = servingIdentities.get(threadId);
if (!entry) return;
servingIdentities.delete(threadId);
servingIdentityTotalBytes -= entry.bytes;
}

function sweepExpiredServingIdentities(at: number): void {
for (const [threadId, entry] of servingIdentities) {
if (at - entry.at >= TTL_MS) deleteServingIdentity(threadId);
}
}

/**
* Compare this request's route with the last route recorded for its client thread, then
* record the current route. A live mismatch means replayed opaque reasoning was minted by
* another backend and must not be forwarded to this one.
*
* Serving provenance uses restart-stable destination and credential dimensions so token
* generations and other volatile credential material cannot create false route changes. Missing
* durable identity, expired, or evicted state is deliberately unknown rather than a mismatch.
* This store is process-local, so a backend switch spanning a proxy restart is not detected.
*/
export function updateReasoningReplayServingIdentity(
scope: OcxReasoningReplayScopeRef | undefined,
): boolean {
const threadId = scope?.clientThreadId;
const identityTuple = tupleForServingIdentity(scope?.current);
if (!nonEmpty(threadId) || !identityTuple) return false;
const identity = JSON.stringify(identityTuple);

const at = now();
sweepExpiredServingIdentities(at);
const previous = servingIdentities.get(threadId);
const changed = previous !== undefined && previous.identity !== identity;
const bytes = Buffer.byteLength(JSON.stringify([threadId, identity]), "utf8");
if (bytes > MAX_TOTAL_BYTES) {
deleteServingIdentity(threadId);
return false;
}

if (previous) deleteServingIdentity(threadId);
servingIdentities.set(threadId, { identity, bytes, at });
servingIdentityTotalBytes += bytes;
while (
(servingIdentityTotalBytes > MAX_TOTAL_BYTES || servingIdentities.size > MAX_ENTRIES)
&& servingIdentities.size > 1
) {
let oldestThreadId: string | undefined;
let oldestAt = Infinity;
for (const [candidateThreadId, entry] of servingIdentities) {
if (entry.at < oldestAt) {
oldestAt = entry.at;
oldestThreadId = candidateThreadId;
}
}
if (oldestThreadId === undefined) break;
deleteServingIdentity(oldestThreadId);
}
return changed;
}

function processLocalIdentity(domain: string, material: string): string {
return createHmac("sha256", replayIdentityKey)
.update(domain)
Expand Down Expand Up @@ -303,6 +401,8 @@ export function peekReasoningForCall(
/** Test-only: reset the cache and optionally pin the clock. */
export function clearReasoningReplayCacheForTests(clock?: (() => number) | null): void {
entries.clear();
servingIdentities.clear();
totalBytes = 0;
servingIdentityTotalBytes = 0;
clockForTests = clock ?? null;
}
6 changes: 6 additions & 0 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
durableReplayCredentialIdentity,
reasoningReplayKeyCredentialIdentity,
reasoningReplayOAuthCredentialIdentity,
updateReasoningReplayServingIdentity,
} from "../../responses/reasoning-replay-cache";
import { awaitThoughtSignatureDurability, thoughtSignatureReplaySalt } from "../../responses/thought-signature-replay";
import { buildCompactV1Output, COMPACT_PROMPT, decodeCompactionSummary, extractCompactUserMessages } from "../../responses/compaction";
Expand Down Expand Up @@ -407,6 +408,11 @@ function bindRouteReasoningReplayScope(args: {
}
: undefined,
);
// Keep this sticky for the whole outbound request: a later auth/key rebind may compare equal
// after the first mismatch, but it cannot make history minted by the prior route decodable.
if (updateReasoningReplayServingIdentity(parsed._reasoningReplayScope)) {
parsed._stripReasoningEncryptedContent = true;
}
}

function nonEmptyProviderApiKey(provider: OcxProviderConfig): string | undefined {
Expand Down
2 changes: 2 additions & 0 deletions src/types/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ export interface OcxParsedRequest {
_clientThreadId?: string;
/** Provider/account/model-bound namespace for process-local raw-reasoning replay. */
_reasoningReplayScope?: OcxReasoningReplayScopeRef;
/** A known in-process route switch requires opaque Responses reasoning blobs to be dropped. */
_stripReasoningEncryptedContent?: boolean;
/**
* Optional authenticated tenant/operator namespace for Cursor thread→conversation derivation.
* When absent (single-operator local proxy), derivation stays local-scoped.
Expand Down
35 changes: 35 additions & 0 deletions structure/04_transports-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,41 @@ replays are explicit and receive the same repair.
These compatibility guards are covered by focused tests and should stay close to the adapters that
need them.

Responses passthrough keeps output-only `status` on any `reasoning` input item that retains opaque
`encrypted_content` because OpenAI-operated backends may bind the blob to that field. The established
raw-`content` rule remains separate: ChatGPT accepts reasoning input only with empty `content`, so a
native blob plus raw content keeps the blob and `status` but still blanks `content`. That shape is a
known unresolved contract conflict, not evidence that either existing rule is safe to broaden. The
blob is kept unless the in-process thread record proves that the current provider, destination,
adapter, model, or credential differs from the route recorded for the prior request on that client
thread. On a proven change the blob and `status` are removed while the reasoning item and its summary
survive; `status` is also removed from blobless reasoning items. Missing, expired, or evicted identity
state is unknown. The comparison uses the durable destination and credential identities with the
provider, adapter, and model, so OAuth token-generation refreshes do not look like backend changes;
when either durable dimension is unavailable it refuses to record rather than falling back to a
volatile identity. The record is deliberately process-local, so a backend switch spanning a proxy
restart is not detected and may still be rejected upstream.

A combo target rotation between turns legitimately changes that serving identity, so the following
turn drops blobs minted by the prior target. This is correct because the new target cannot decode
them, but it is intentionally unobvious to the client: `pickComboTarget` keys selection state only by
combo id, without a conversation dimension, and the SSE model-name rewrite preserves the requested
combo name instead of exposing the concrete target switch. A user can therefore observe a reasoning
cache drop with no visible model change.

The image and web-search auxiliary loops consume `_reasoningReplayScope` for bridge-level replay but
never call `bindRouteReasoningReplayScope`, so their internal small-model requests do not update the
serving-identity record. That omission is intentional: binding those routes would poison the main
conversation's last-serving identity and cause a later main-model turn to strip valid blobs.

[Decision Log]
- 목적과 의도: Keep same-backend opaque reasoning replay while preventing backend-private blobs and output-only fields from breaking the first turn after a route change.
- 기존 구현 및 제약 조건: Reasoning-input sanitation already handled raw content and `ocxr1:` envelopes; the replay cache already supplied a bounded, thread-scoped physical-route identity, but no record connected that identity to native `encrypted_content` provenance.
- 검토한 주요 대안: Strip every opaque blob, persist provenance across restarts, retry after an upstream 4xx, or compare and strip before the first outbound request only when an in-process record proves a route change.
- 선택한 방식: Preserve `status` whenever its blob is forwarded without changing the pre-existing raw-`content` blanking rule; otherwise remove output-only `status`, compare and update a 64-entry/256 KiB/one-hour in-process serving-identity record using durable destination and credential dimensions at request time, and pass the proven-change decision into the Responses adapter to remove foreign `encrypted_content`.
- 다른 대안 대신 이 방식을 선택한 이유: Unknown provenance can still be valid after restart, durable storage is unnecessary for this bounded compatibility hint, and a deterministic pre-flight decision avoids a second paid or stateful upstream attempt.
- 장점, 단점 및 영향: Same-route and unknown replay retain cached reasoning, known cross-route replay keeps the reasoning item without its undecodable blob, and switches spanning a proxy restart remain an explicit coverage gap.

DeepSeek's stateless Responses compatibility pass normalizes only unambiguous tool-call batches.
Calls emitted before the first matched output stay together as one assistant batch, followed by
their outputs in call order; hook-injected messages that split the batch move after it without being
Expand Down
Loading
Loading