feat(api): deterministic request-body serialization via stableStringify (#882)
* feat(api): deterministic request-body serialization via stableStringify Add `stableStringify` helper that emits JSON with object keys sorted lexicographically at every depth (arrays preserved). Adopt it in the OpenAI-compatible shim and the Codex Responses-API shim for the outgoing request body. WHY: OpenAI / Kimi / DeepSeek / Codex use implicit prefix caching keyed on exact request bytes. Spurious insertion-order differences in spread-merged body objects otherwise invalidate the cache on every turn. Also a pre-requisite for Anthropic `cache_control` breakpoint hits. Byte-equivalent to `JSON.stringify` when keys already happen to be in lexical insertion order, so strictly additive across providers. * fix(api): preserve circular-ref TypeError in stableStringify + cover GitHub fallback Replace two-pass sortingReplacer approach with a single-pass deepSort that tracks ancestor objects via WeakSet, throwing TypeError on cycles (same contract as native JSON.stringify) and correctly handling DAGs via try/finally cleanup. Switch the GitHub Copilot /responses fallback in openaiShim.ts from JSON.stringify to stableStringify so that path is also byte-stable for prefix caching. Regression coverage added: top-level cycle, deep nested cycle, DAG safety. * fix(api): align stableStringify with native JSON.stringify pre-processing Replicate native JSON.stringify pre-processing inside deepSort so serialization output matches native behavior beyond key ordering: - invoke toJSON(key) when present (Date, URL, user classes); pass '' at top-level, property name for nested values, index string for array elements - unbox Number/String/Boolean wrappers via valueOf() so new Boolean(false) doesn't get truthy-coerced - run cycle detection on the post-toJSON value so a toJSON returning an ancestor still throws TypeError; DAGs continue to not throw - drop properties whose toJSON returns undefined, matching native Add focused stableStringify.test.ts (21 cases) asserting equality with JSON.stringify across toJSON paths, wrapper unboxing, cycle/DAG handling, and sortKeysDeep parity.
This commit is contained in:
@@ -74,7 +74,12 @@ import {
|
||||
hasToolFieldMapping,
|
||||
} from './toolArgumentNormalization.js'
|
||||
import { logApiCallStart, logApiCallEnd } from '../../utils/requestLogging.js'
|
||||
import { createStreamState, processStreamChunk, getStreamStats } from '../../utils/streamingOptimizer.js'
|
||||
import {
|
||||
createStreamState,
|
||||
processStreamChunk,
|
||||
getStreamStats,
|
||||
} from '../../utils/streamingOptimizer.js'
|
||||
import { stableStringify } from '../../utils/stableStringify.js'
|
||||
|
||||
type SecretValueSource = Partial<{
|
||||
OPENAI_API_KEY: string
|
||||
@@ -1852,12 +1857,17 @@ class OpenAIShimMessages {
|
||||
return false
|
||||
}
|
||||
|
||||
let serializedBody = JSON.stringify(
|
||||
// WHY: byte-identity required for implicit prefix caching in
|
||||
// OpenAI/Kimi/DeepSeek. stableStringify sorts object keys at every
|
||||
// depth so spurious insertion-order differences across rebuilds of
|
||||
// `body` (spread-merge, conditional assignments above) don't bust
|
||||
// the provider's prefix hash.
|
||||
let serializedBody = stableStringify(
|
||||
request.transport === 'responses' ? buildResponsesBody() : body,
|
||||
)
|
||||
|
||||
const refreshSerializedBody = (): void => {
|
||||
serializedBody = JSON.stringify(
|
||||
serializedBody = stableStringify(
|
||||
request.transport === 'responses' ? buildResponsesBody() : body,
|
||||
)
|
||||
}
|
||||
@@ -2036,7 +2046,7 @@ class OpenAIShimMessages {
|
||||
responsesResponse = await fetchWithProxyRetry(responsesUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(responsesBody),
|
||||
body: stableStringify(responsesBody),
|
||||
signal: options?.signal,
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user