Skip to content
Merged
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
2 changes: 1 addition & 1 deletion src/adapters/openai-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ function toolResultImageChatParts(content: string | OcxContentPart[]): unknown[]
function messagesToChatFormat(parsed: OcxParsedRequest, provider: OcxProviderConfig): unknown[] {
const out: unknown[] = [];
const { context, options } = parsed;
const replayCacheScope = parsed._clientThreadId;
const replayCacheScope = parsed._reasoningReplayScope;

interface PendingToolCall { id: string; name: string }
let pendingToolCalls: PendingToolCall[] = [];
Expand Down
12 changes: 9 additions & 3 deletions src/bridge.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, OcxUsage } from "./types";
import type {
AdapterEvent,
OcxMessagePhase,
OcxProviderContinuationState,
OcxReasoningReplayScopeRef,
OcxUsage,
} from "./types";
import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors";
import { encodeCompactionSummary } from "./responses/compaction";
import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope";
Expand Down Expand Up @@ -186,7 +192,7 @@ export function bridgeToResponsesSSE(
* Provider call ids are not globally unique; scoping by thread keeps one
* conversation's reasoning out of another's continuations.
*/
replayCacheScope?: string;
replayCacheScope?: OcxReasoningReplayScopeRef;
/**
* Test seam for the wire/stall beat loop. Production omits this and uses the
* global timers; injecting here must not change scheduling semantics.
Expand Down Expand Up @@ -1362,7 +1368,7 @@ function buildResponseJSONWithBudget(
onUsage?: (usage: OcxUsage | undefined) => void;
translatorBudget?: TranslatorBudget;
/** Conversation identity for the reasoning replay cache (issue #950). */
replayCacheScope?: string;
replayCacheScope?: OcxReasoningReplayScopeRef;
},
): Record<string, unknown> {
const responseId = `resp_${uuid()}`;
Expand Down
2 changes: 1 addition & 1 deletion src/images/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -907,7 +907,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise<Respons
}, 2_000,
{
translatorBudget,
replayCacheScope: parsed._clientThreadId,
replayCacheScope: parsed._reasoningReplayScope,
...(deps.forceEmptyResponseId ? { responseId: "" } : {}),
hideThinkingSummary: parsed.options.hideThinkingSummary,
stallTimeoutSec: deps.stallTimeoutSec,
Expand Down
166 changes: 160 additions & 6 deletions src/responses/reasoning-replay-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,32 @@
* long-lived proxy cannot grow without limit.
*/

import { createHmac, randomBytes } from "node:crypto";
import type {
OcxProviderConfig,
OcxReasoningReplayIdentity,
OcxReasoningReplayScopeRef,
} from "../types";

const MAX_ENTRIES = 64;
const MAX_TOTAL_BYTES = 256 * 1024;
const TTL_MS = 60 * 60 * 1000;
const replayIdentityKey = randomBytes(32);
const CREDENTIAL_HEADER_NAMES = new Set([
"authorization",
"api-key",
"chatgpt-account-id",
"cookie",
"openai-organization",
"openai-project",
"proxy-authorization",
"x-api-key",
"x-api-token",
"x-auth-token",
"x-goog-api-key",
"x-openai-organization",
"x-openai-project",
]);

interface CacheEntry {
text: string;
Expand All @@ -34,21 +57,150 @@ let totalBytes = 0;
let clockForTests: (() => number) | null = null;

const now = (): number => clockForTests?.() ?? Date.now();
const keyFor = (callId: string, scope: string): string => `${scope}\u0000${callId}`;

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;
if (
!nonEmpty(callId)
|| !nonEmpty(scope?.clientThreadId)
|| !nonEmpty(identity?.providerName)
|| !nonEmpty(identity?.providerDestinationIdentity)
|| !nonEmpty(identity?.adapterName)
|| !nonEmpty(identity?.modelId)
|| !nonEmpty(identity?.credentialIdentity)
) return undefined;
return JSON.stringify([
scope.clientThreadId,
identity.providerName,
identity.providerDestinationIdentity,
identity.adapterName,
identity.modelId,
identity.credentialIdentity,
callId,
]);
}

function processLocalIdentity(domain: string, material: string): string {
return createHmac("sha256", replayIdentityKey)
.update(domain)
.update("\0")
.update(material)
.digest("hex");
}

function credentialHeaderOverrides(headers: Record<string, string> | undefined): [string, string][] {
return Object.entries(headers ?? {})
.filter(([name, value]) => CREDENTIAL_HEADER_NAMES.has(name.trim().toLowerCase()) && nonEmpty(value))
.map(([name, value]) => [name.trim().toLowerCase(), value] as [string, string])
.sort(([leftName, leftValue], [rightName, rightValue]) => (
leftName.localeCompare(rightName) || leftValue.localeCompare(rightValue)
));
}

/** Produce a non-reversible process-local identity for an exact upstream destination. */
export function reasoningReplayDestinationIdentity(baseUrl: string | undefined): string | undefined {
if (!nonEmpty(baseUrl)) return undefined;
const canonical = baseUrl.trim().replace(/\/+$/, "");
return `destination:${processLocalIdentity("destination", canonical)}`;
}

/** Produce a non-reversible process-local identity for credential material. */
export function reasoningReplayCredentialIdentity(
kind: "key" | "oauth" | "codex",
material: string | undefined,
headers?: Record<string, string>,
): string | undefined {
if (!nonEmpty(material)) return undefined;
const overrides = credentialHeaderOverrides(headers);
return `${kind}:${processLocalIdentity(`credential:${kind}`, JSON.stringify([material, overrides]))}`;
}

/** Bind Codex forwarding to the effective bearer/account and selected physical pool slot. */
export function reasoningReplayCodexCredentialIdentity(args: {
authorization?: string | null;
chatgptAccountId?: string | null;
accountId?: string | null;
credentialGeneration?: number | string | null;
writerGeneration?: number | string | null;
headers?: Record<string, string>;
}): string | undefined {
const configuredAuthorization = credentialHeaderOverrides(args.headers)
.find(([name]) => name === "authorization")?.[1];
const authorization = nonEmpty(args.authorization) ? args.authorization : configuredAuthorization;
if (!authorization) return undefined;
const material = JSON.stringify([
authorization,
nonEmpty(args.chatgptAccountId) ? args.chatgptAccountId : "",
nonEmpty(args.accountId) ? args.accountId : "",
args.credentialGeneration === null || args.credentialGeneration === undefined
? ""
: String(args.credentialGeneration),
args.writerGeneration === null || args.writerGeneration === undefined
? ""
: String(args.writerGeneration),
]);
return reasoningReplayCredentialIdentity("codex", material, args.headers);
}

/** Bind OAuth replay to one persisted credential slot and exact token generation. */
export function reasoningReplayOAuthCredentialIdentity(
snapshot: Readonly<{ accountId: string; generation: string }> | undefined,
headers?: Record<string, string>,
): string | undefined {
if (!snapshot || !nonEmpty(snapshot.accountId) || !nonEmpty(snapshot.generation)) return undefined;
return reasoningReplayCredentialIdentity(
"oauth",
`${snapshot.accountId}\0${snapshot.generation}`,
headers,
);
}

/** Bind key-auth provider material without putting raw secrets in the replay key. */
export function reasoningReplayKeyCredentialIdentity(
provider: Pick<OcxProviderConfig, "apiKey" | "headers">,
): string | undefined {
const apiKey = nonEmpty(provider.apiKey) ? provider.apiKey : undefined;
// Public/static headers do not establish a physical credential boundary.
// Header-only/key-optional providers therefore fail closed for replay.
if (!apiKey) return undefined;
return reasoningReplayCredentialIdentity("key", apiKey, provider.headers);
}

/** Replace only the holder snapshot so parsed-request copies observe rotations. */
export function bindReasoningReplayScope(
scope: OcxReasoningReplayScopeRef | undefined,
identity: OcxReasoningReplayIdentity | undefined,
): void {
if (!scope) return;
if (!identity || !keyFor("binding-check", { ...scope, current: identity })) {
delete scope.current;
return;
}
scope.current = { ...identity };
}

/**
* Record the raw reasoning text that preceded the given tool call.
*
* Expired entries are swept on insert so the TTL bound holds even when a call
* id is never read again.
*/
export function rememberReasoningForCall(callId: string, text: string, scope?: string): void {
export function rememberReasoningForCall(
callId: string,
text: string,
scope?: OcxReasoningReplayScopeRef,
): void {
// Never fall back to a process-wide namespace. Call ids are supplied by
// clients/providers and are therefore neither unique nor trustworthy; an
// unscoped entry could be recovered by an unrelated request that reuses the
// same id.
// Empty provider deltas are absence of new reasoning, not a request to erase a candidate.
if (!scope || !callId || typeof text !== "string" || text.length === 0) return;
const key = keyFor(callId, scope);
if (!key || typeof text !== "string" || text.length === 0) return;
const bytes = Buffer.byteLength(text, "utf8");
// A single entry larger than the whole budget would immediately evict itself.
if (bytes > MAX_TOTAL_BYTES) return;
Expand All @@ -61,7 +213,6 @@ export function rememberReasoningForCall(callId: string, text: string, scope?: s
totalBytes -= entry.bytes;
}
}
const key = keyFor(callId, scope);
const previous = entries.get(key);
if (previous) totalBytes -= previous.bytes;
entries.set(key, { text, bytes, at });
Expand All @@ -88,9 +239,12 @@ export function rememberReasoningForCall(callId: string, text: string, scope?: s
* Read the recorded reasoning for a call id without removing it: retries after
* a failed continuation reuse the same fallback.
*/
export function peekReasoningForCall(callId: string, scope?: string): string | undefined {
if (!scope || !callId) return undefined;
export function peekReasoningForCall(
callId: string,
scope?: OcxReasoningReplayScopeRef,
): string | undefined {
const key = keyFor(callId, scope);
if (!key) return undefined;
const entry = entries.get(key);
if (!entry) return undefined;
if (now() - entry.at >= TTL_MS) {
Expand Down
Loading
Loading