Compare commits

..

3 Commits

Author SHA1 Message Date
gnanam1990
9906b33fa0 fix(typecheck): make bun run typecheck actionable on main (#473)
Issue #473 reported that `bun run typecheck` fails on main with ~4400
errors due to repo-foundation drift, masking branch-specific
regressions. Per kevincodex1's guidance ("lets narrow the typecheck
scope for now and then we expand step by step") this PR addresses the
foundational root causes and brings the error count down 60% so the
gate is actionable for branch reviews.

Changes:

- tsconfig.json: bump target to ES2023 + add lib ["ES2023", "DOM"]
  so Array.findLast / findLastIndex resolve (kills 41 TS2550 errors).
  Add `noEmit: true` for typecheck-only mode and
  `allowImportingTsExtensions: true` (kills 40 TS5097 errors). Set
  `noImplicitAny: false` because cleaning up TSX-component implicit
  any is explicitly out of scope per the issue.

- src/global.d.ts: ambient declaration for the build-time MACRO
  global injected by scripts/build.ts via Bun's `define` option
  (kills 9 TS2304 'Cannot find name MACRO' errors).

- src/types/{message,utils,tools}.ts: stubs for the highest-impact
  missing modules from the partial source snapshot (~21 importers
  for message alone). Document the snapshot caveat at the top of each
  stub and reference issue #473 so future readers know they're
  placeholders.

- src/entrypoints/sdk/controlTypes.ts and src/constants/querySource.ts:
  similar one-file stubs unblocking 18 + 19 importers respectively.

- src/entrypoints/agentSdkTypes.ts: append `any`-typed aliases for
  ~70 SDK names that callers expect on the public surface but that
  live in stubbed sub-files (PermissionMode, SDKCompactBoundaryMessage,
  HookEvent, ModelUsage, ModelInfo, etc. — exactly the list from
  auriti's bug-report enumeration).

Verified locally on Linux:
- baseline `bunx tsc --noEmit` on stashed main: 4434 errors
- with PR applied:                              1782 errors (60% drop)
- `bun run build`:                              passes (v0.7.0)
- `bun test`:                                   1632 pass; the 4
   remaining failures (StartupScreen, thinking) reproduce on main
   and are unrelated.
- TS2550 (lib): 41 → 0
- TS5097 (.ts imports): 40 → 0
- TS2304 'MACRO': 9 → 0
- TS2307 missing modules: 587 → 325

Remaining errors are localized to specific stubbed modules and can
be addressed in smaller follow-up issues, matching the issue's
"Definition of done" criterion.
2026-04-28 12:44:12 +05:30
viudes
6ea3eb6483 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.
2026-04-27 23:33:15 +08:00
vrdons
f699c1f2fc fix routing path (#923) 2026-04-27 20:05:17 +08:00
14 changed files with 657 additions and 10 deletions

View File

@@ -170,7 +170,7 @@ For best results, use models with strong tool/function calling support.
OpenClaude can route different agents to different models through settings-based routing. This is useful for cost optimization or splitting work by model strength.
Add to `~/.claude/settings.json`:
Add to `~/.openclaude.json`:
```json
{

View File

@@ -0,0 +1,7 @@
/**
* Stub — query source enum not included in source snapshot. See
* src/types/message.ts for the same scoping caveat (issue #473).
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export type QuerySource = any

View File

@@ -442,7 +442,84 @@ export async function connectRemoteControl(
throw new Error('not implemented')
}
// add exit reason types for removing the error within gracefulShutdown file
// add exit reason types for removing the error within gracefulShutdown file
export type ExitReason = {
}
}
// ============================================================================
// Stub re-exports — types not included in source snapshot.
//
// The upstream Anthropic SDK defines these in sub-files (sdk/coreTypes,
// sdk/runtimeTypes, sdk/controlTypes, sdk/toolTypes) that are stubbed
// in this open repo. Until the real definitions are restored, alias the
// names to `any` so callers can resolve their imports and `tsc` becomes
// actionable. See issue #473 for the typecheck-foundation effort.
// ============================================================================
/* eslint-disable @typescript-eslint/no-explicit-any */
export type AnyZodRawShape = any
export type ApiKeySource = any
export type AsyncHookJSONOutput = any
export type ConfigChangeHookInput = any
export type CwdChangedHookInput = any
export type ElicitationHookInput = any
export type ElicitationResultHookInput = any
export type FileChangedHookInput = any
export type ForkSessionOptions = any
export type ForkSessionResult = any
export type GetSessionInfoOptions = any
export type GetSessionMessagesOptions = any
export type HookEvent = any
export type HookInput = any
export type HookJSONOutput = any
export type InferShape<_T> = any
export type InstructionsLoadedHookInput = any
export type InternalOptions = any
export type InternalQuery = any
export type ListSessionsOptions = any
export type McpSdkServerConfigWithInstance = any
export type McpServerConfigForProcessTransport = any
export type McpServerStatus = any
export type ModelInfo = any
export type ModelUsage = any
export type NotificationHookInput = any
export type Options = any
export type PermissionDeniedHookInput = any
export type PermissionMode = any
export type PermissionRequestHookInput = any
export type PermissionResult = any
export type PermissionUpdate = any
export type PostCompactHookInput = any
export type PostToolUseFailureHookInput = any
export type PostToolUseHookInput = any
export type PreCompactHookInput = any
export type PreToolUseHookInput = any
export type Query = any
export type RewindFilesResult = any
export type SDKAssistantMessage = any
export type SDKAssistantMessageError = any
export type SDKCompactBoundaryMessage = any
export type SdkMcpToolDefinition = any
export type SDKPartialAssistantMessage = any
export type SDKPermissionDenial = any
export type SDKRateLimitInfo = any
export type SDKStatus = any
export type SDKStatusMessage = any
export type SDKSystemMessage = any
export type SDKToolProgressMessage = any
export type SDKUserMessageReplay = any
export type SessionEndHookInput = any
export type SessionMessage = any
export type SessionMutationOptions = any
export type SessionStartHookInput = any
export type SetupHookInput = any
export type StopFailureHookInput = any
export type StopHookInput = any
export type SubagentStartHookInput = any
export type SubagentStopHookInput = any
export type SyncHookJSONOutput = any
export type TaskCompletedHookInput = any
export type TaskCreatedHookInput = any
export type TeammateIdleHookInput = any
export type UserPromptSubmitHookInput = any

View File

@@ -0,0 +1,10 @@
/**
* Stub — control protocol types not included in source snapshot. See
* src/types/message.ts for the same scoping caveat (issue #473).
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export type SDKControlRequest = any
export type SDKControlResponse = any
export type SDKControlPermissionRequest = any
export type StdoutMessage = any

16
src/global.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
/**
* Build-time globals replaced by the bundler at build time.
*
* `scripts/build.ts` substitutes these via Bun's `define` option, so at
* runtime the references are inlined as string literals. This declaration
* exists only to make `tsc --noEmit` aware of them — without it, every
* `MACRO.*` access fires TS2304 "Cannot find name 'MACRO'".
*/
declare const MACRO: {
VERSION: string
DISPLAY_VERSION: string
BUILD_TIME: string
ISSUES_EXPLAINER: string
PACKAGE_URL: string
NATIVE_PACKAGE_URL: string | undefined
}

View File

@@ -2,6 +2,7 @@ import { APIError } from '@anthropic-ai/sdk'
import { buildAnthropicUsageFromRawUsage } from './cacheMetrics.js'
import { compressToolHistory } from './compressToolHistory.js'
import { fetchWithProxyRetry } from './fetchWithProxyRetry.js'
import { stableStringify } from '../../utils/stableStringify.js'
import type {
ResolvedCodexCredentials,
ResolvedProviderRequest,
@@ -559,7 +560,9 @@ export async function performCodexRequest(options: {
{
method: 'POST',
headers,
body: JSON.stringify(body),
// WHY: byte-identity required for implicit prefix caching on
// OpenAI Responses API. See src/utils/stableStringify.ts.
body: stableStringify(body),
signal: options.signal,
},
)

View File

@@ -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) {

25
src/types/message.ts Normal file
View File

@@ -0,0 +1,25 @@
/**
* Stub — message type definitions not included in source snapshot.
*
* The upstream Anthropic source defines a rich Message discriminated union
* with structured Content blocks, role tags, tool_use payloads, and so on.
* That file is not mirrored to this open snapshot. This stub exists so
* `tsc --noEmit` can resolve `import { Message, ... } from 'src/types/message'`
* across the ~21 callers without fixing every transitive type the call
* sites use.
*
* Once the real definitions are restored upstream-side or reconstructed
* from runtime usage, replace these `any` aliases with proper types and
* delete this comment. See issue #473 for the typecheck-foundation effort.
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export type Message = any
export type AssistantMessage = any
export type UserMessage = any
export type SystemMessage = any
export type SystemAPIErrorMessage = any
export type AttachmentMessage = any
export type ProgressMessage = any
export type HookResultMessage = any
export type NormalizedUserMessage = any

7
src/types/tools.ts Normal file
View File

@@ -0,0 +1,7 @@
/**
* Stub — tool type definitions not included in source snapshot. See
* src/types/message.ts for the same scoping caveat (issue #473).
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export type ShellProgress = any

15
src/types/utils.ts Normal file
View File

@@ -0,0 +1,15 @@
/**
* Stub — utility type definitions not included in source snapshot. See
* src/types/message.ts for the same scoping caveat (issue #473).
*/
/* eslint-disable @typescript-eslint/no-explicit-any */
export type DeepImmutable<T> = T extends any[]
? readonly DeepImmutable<T[number]>[]
: T extends object
? { readonly [K in keyof T]: DeepImmutable<T[K]> }
: T
export type Permutations<T extends string, U extends string = T> = T extends T
? T | `${T}${Permutations<Exclude<U, T>>}`
: never

View File

@@ -0,0 +1,142 @@
import { describe, expect, test } from 'bun:test'
import { sortKeysDeep, stableStringify } from './stableStringify.js'
// These tests pin byte-level stability of serialization helpers. The
// invariant that matters for implicit prefix caching in OpenAI / Kimi /
// DeepSeek / Codex — and for Anthropic cache_control breakpoints — is:
// semantically-equal inputs must produce byte-identical output across
// invocations and across key-order permutations.
describe('stableStringify', () => {
test('two invocations with the same object produce identical strings', () => {
const a = stableStringify({ b: 1, a: 2 })
const b = stableStringify({ b: 1, a: 2 })
expect(a).toBe(b)
})
test('key order at the top level does not affect output', () => {
expect(stableStringify({ a: 1, b: 2 })).toBe(stableStringify({ b: 2, a: 1 }))
})
test('key order at nested depths does not affect output', () => {
const x = { outer: { z: 1, a: 2, m: { b: 3, a: 4 } } }
const y = { outer: { m: { a: 4, b: 3 }, a: 2, z: 1 } }
expect(stableStringify(x)).toBe(stableStringify(y))
})
test('array element order IS preserved (semantic in API contracts)', () => {
expect(stableStringify({ messages: ['a', 'b', 'c'] })).not.toBe(
stableStringify({ messages: ['c', 'b', 'a'] }),
)
})
test('arrays of objects have keys sorted inside each element', () => {
const out = stableStringify({
tools: [
{ name: 'Bash', description: 'run' },
{ description: 'read', name: 'Read' },
],
})
expect(out).toBe(
'{"tools":[{"description":"run","name":"Bash"},{"description":"read","name":"Read"}]}',
)
})
test('undefined values are omitted (matches JSON.stringify)', () => {
const out = stableStringify({ a: undefined, b: 1 })
expect(out).toBe('{"b":1}')
})
test('primitive and null pass through unchanged', () => {
expect(stableStringify(null)).toBe('null')
expect(stableStringify(42)).toBe('42')
expect(stableStringify('x')).toBe('"x"')
expect(stableStringify(true)).toBe('true')
})
test('throws TypeError on circular structures (same behavior as JSON.stringify)', () => {
const obj: Record<string, unknown> = {}
obj.self = obj
// The exact message varies by engine (V8: "Converting circular structure
// to JSON", Bun: "JSON.stringify cannot serialize cyclic structures.").
// We only pin the error class — same contract as native JSON.stringify.
expect(() => stableStringify(obj)).toThrow(TypeError)
expect(() => JSON.stringify(obj)).toThrow(TypeError)
})
test('throws TypeError on circular references nested deep in the graph', () => {
const inner: Record<string, unknown> = { val: 1 }
const outer = { a: { b: inner } }
inner.cycle = outer
expect(() => stableStringify(outer)).toThrow(TypeError)
})
test('does not throw on DAGs (same object referenced from multiple keys)', () => {
const shared = { x: 1 }
// Native JSON.stringify handles this fine — stableStringify must too.
expect(() => stableStringify({ a: shared, b: shared })).not.toThrow()
expect(stableStringify({ a: shared, b: shared })).toBe(
'{"a":{"x":1},"b":{"x":1}}',
)
})
})
describe('sortKeysDeep', () => {
test('returns an object with sorted keys at every depth', () => {
const sorted = sortKeysDeep({
b: 1,
a: { y: 2, x: { d: 3, c: 4 } },
}) as Record<string, unknown>
expect(Object.keys(sorted)).toEqual(['a', 'b'])
expect(Object.keys(sorted.a as Record<string, unknown>)).toEqual([
'x',
'y',
])
})
test('arrays are preserved element-wise', () => {
const sorted = sortKeysDeep([
{ b: 1, a: 2 },
{ d: 3, c: 4 },
]) as Array<Record<string, unknown>>
expect(Object.keys(sorted[0]!)).toEqual(['a', 'b'])
expect(Object.keys(sorted[1]!)).toEqual(['c', 'd'])
})
})
describe('prefix caching invariants — end-to-end', () => {
// This is the real payload shape that an OpenAI-compatible body
// takes on its way to the upstream provider. We exercise it via
// stableStringify to verify that rebuilding the body with different
// key insertion orders yields the same bytes.
const bodyA = {
model: 'gpt-4o-mini',
stream: true,
messages: [
{ role: 'system', content: 'you are helpful' },
{ role: 'user', content: 'hi' },
],
tools: [{ name: 't', description: 'x' }],
temperature: 0.7,
top_p: 1,
}
const bodyB = {
top_p: 1,
temperature: 0.7,
tools: [{ description: 'x', name: 't' }],
messages: [
{ content: 'you are helpful', role: 'system' },
{ content: 'hi', role: 'user' },
],
stream: true,
model: 'gpt-4o-mini',
}
test('two spread-merged request bodies produce identical stable bytes', () => {
expect(stableStringify(bodyA)).toBe(stableStringify(bodyB))
})
test('calling stableStringify twice yields identical bytes (idempotent)', () => {
expect(stableStringify(bodyA)).toBe(stableStringify(bodyA))
})
})

View File

@@ -0,0 +1,199 @@
import { describe, expect, test } from 'bun:test'
import { sortKeysDeep, stableStringify } from './stableStringify'
/**
* Contract: `stableStringify(input)` must equal `JSON.stringify(input)`
* for every value where the latter is well-defined, except that object
* keys are emitted in lexicographic order at every depth. These tests
* focus on the native pre-processing semantics — `toJSON(key)` and
* primitive-wrapper unboxing — that the deep-sort path must preserve.
*/
describe('stableStringify — toJSON semantics', () => {
test('Date at top level → ISO string', () => {
const d = new Date('2024-01-02T03:04:05.678Z')
expect(stableStringify(d)).toBe(JSON.stringify(d))
})
test('Date nested in object → ISO string + sorted keys', () => {
const d = new Date('2024-01-02T03:04:05.678Z')
const input = { z: 1, when: d, a: 'x' }
expect(stableStringify(input)).toBe(
`{"a":"x","␟when␟":"PLACEHOLDER","z":1}`
.replace('␟when␟', 'when')
.replace('"PLACEHOLDER"', JSON.stringify(d.toISOString())),
)
})
test('Date inside an array → each element converted', () => {
const a = new Date('2024-01-02T03:04:05.678Z')
const b = new Date('2025-06-07T08:09:10.111Z')
const input = [a, b]
expect(stableStringify(input)).toBe(JSON.stringify(input))
})
test('URL value serializes via URL.prototype.toJSON', () => {
const u = new URL('https://example.com/path?q=1')
expect(stableStringify(u)).toBe(JSON.stringify(u))
expect(stableStringify({ url: u })).toBe(JSON.stringify({ url: u }))
})
test('custom class with toJSON returning a plain object → keys sorted', () => {
class Thing {
toJSON() {
return { z: 1, a: 2, m: 3 }
}
}
const out = stableStringify(new Thing())
expect(out).toBe('{"a":2,"m":3,"z":1}')
})
test('toJSON(key) receives the property name for object values', () => {
const seen: string[] = []
class Trace {
toJSON(k: string) {
seen.push(k)
return k
}
}
const t = new Trace()
stableStringify({ alpha: t, beta: t })
// Object keys are sorted, so toJSON is invoked alpha-first.
expect(seen).toEqual(['alpha', 'beta'])
})
test('toJSON(key) receives the array index as a string for array elements', () => {
const seen: string[] = []
class Trace {
toJSON(k: string) {
seen.push(k)
return k
}
}
const t = new Trace()
stableStringify([t, t, t])
expect(seen).toEqual(['0', '1', '2'])
})
test('toJSON(key) receives empty string at top level', () => {
let captured: string | undefined
class Trace {
toJSON(k: string) {
captured = k
return 'ok'
}
}
stableStringify(new Trace())
expect(captured).toBe('')
})
test('toJSON returning undefined drops the property (matches native)', () => {
class Hidden {
toJSON() {
return undefined
}
}
const input = { a: 1, gone: new Hidden(), b: 2 }
expect(stableStringify(input)).toBe(JSON.stringify(input))
expect(stableStringify(input)).toBe('{"a":1,"b":2}')
})
test('nested mix: object with a Date field and a regular field → keys sorted, Date as ISO', () => {
const d = new Date('2024-01-02T03:04:05.678Z')
const input = { z: { when: d, a: 1 }, a: 'first' }
expect(stableStringify(input)).toBe(
`{"a":"first","z":{"a":1,"when":${JSON.stringify(d.toISOString())}}}`,
)
})
})
describe('stableStringify — primitive wrapper unboxing', () => {
test('new Number at top level → numeric primitive', () => {
const n = new Number(42)
expect(stableStringify(n)).toBe(JSON.stringify(n))
expect(stableStringify(n)).toBe('42')
})
test('new String at top level → string primitive', () => {
const s = new String('hello')
expect(stableStringify(s)).toBe(JSON.stringify(s))
expect(stableStringify(s)).toBe('"hello"')
})
test('new Boolean at top level → boolean primitive', () => {
const b = new Boolean(true)
expect(stableStringify(b)).toBe(JSON.stringify(b))
expect(stableStringify(b)).toBe('true')
})
test('new Boolean(false) at top level → false', () => {
const b = new Boolean(false)
expect(stableStringify(b)).toBe(JSON.stringify(b))
expect(stableStringify(b)).toBe('false')
})
test('boxed wrappers as object values → primitives + sorted keys', () => {
const input = {
z: new Number(1),
a: new String('x'),
m: new Boolean(false),
}
expect(stableStringify(input)).toBe('{"a":"x","m":false,"z":1}')
// Native form: same primitive shape (without sort guarantee).
expect(JSON.parse(stableStringify(input))).toEqual(JSON.parse(JSON.stringify(input)))
})
})
describe('stableStringify — cycles vs DAGs', () => {
test('top-level cycle throws TypeError (regression guard)', () => {
const obj: Record<string, unknown> = { a: 1 }
obj.self = obj
expect(() => stableStringify(obj)).toThrow(TypeError)
})
test('deep cycle throws TypeError', () => {
const a: Record<string, unknown> = { name: 'a' }
const b: Record<string, unknown> = { name: 'b' }
a.next = b
b.back = a
expect(() => stableStringify(a)).toThrow(TypeError)
})
test('toJSON returning an ancestor still triggers the cycle check', () => {
type Node = { name: string; child?: { toJSON(): Node } }
const parent: Node = { name: 'parent' }
parent.child = {
toJSON() {
return parent
},
}
expect(() => stableStringify(parent)).toThrow(TypeError)
})
test('DAG (same object referenced twice via different keys) does NOT throw', () => {
const shared = { v: 1 }
const input = { left: shared, right: shared }
expect(() => stableStringify(input)).not.toThrow()
expect(stableStringify(input)).toBe('{"left":{"v":1},"right":{"v":1}}')
})
test('DAG of arrays does NOT throw', () => {
const shared = [1, 2, 3]
const input = { a: shared, b: shared }
expect(() => stableStringify(input)).not.toThrow()
expect(stableStringify(input)).toBe('{"a":[1,2,3],"b":[1,2,3]}')
})
})
describe('sortKeysDeep — same toJSON/unbox semantics', () => {
test('returns the post-toJSON, post-unbox sorted shape', () => {
const d = new Date('2024-01-02T03:04:05.678Z')
const out = sortKeysDeep({ z: 1, a: new Number(7), when: d }) as Record<
string,
unknown
>
expect(out).toEqual({ a: 7, when: d.toISOString(), z: 1 })
// Key order in the returned object is lexicographic.
expect(Object.keys(out)).toEqual(['a', 'when', 'z'])
})
})

View File

@@ -0,0 +1,132 @@
/**
* Deterministic JSON serialization.
*
* WHY: OpenAI / Kimi / DeepSeek / Codex all use **implicit prefix caching**
* — the server hashes the request prefix and reuses cached reasoning if
* the bytes match exactly. Even a trivial key-order difference between
* two otherwise-identical requests invalidates the hash and forces a
* full re-parse.
*
* This is also a pre-requisite for Anthropic / Bedrock / Vertex
* `cache_control` breakpoints: ephemeral cache entries match on exact
* content, so a re-ordered object literal busts the breakpoint.
*
* `JSON.stringify` is nondeterministic across engines and across
* successive iterations when objects carry keys added at different
* times (V8 preserves insertion order, which is the common failure
* mode when building a body from spread-merged configs).
*
* This helper recursively sorts object keys. Arrays preserve order
* (element order IS semantically significant in message/content arrays).
*
* Complements `sortKeysDeep` in src/services/remoteManagedSettings and
* src/services/policyLimits. Those two are INTENTIONALLY separate:
* - remoteManagedSettings: matches Python `json.dumps(sort_keys=True)`
* byte-for-byte to validate server-computed checksums. Must NOT
* drop undefined (Python preserves null).
* - policyLimits: uses `localeCompare` (keeps legacy behavior; locale-
* sensitive but stable for a given runtime).
* - this module (stableStringify): byte-identity for API body caching.
* Drops undefined to match `JSON.stringify` — the openaiShim/codexShim
* body is always downstream of `JSON.stringify` semantics.
* Do not consolidate without auditing the 3 callers — each has a
* different server-compat contract.
*/
/**
* Returns a byte-stable JSON string representation.
* - Object keys are emitted in lexicographic order at every depth.
* - Array element order is preserved.
* - Undefined values are dropped (matching `JSON.stringify`).
* - Indentation matches the `space` argument (0 by default → compact).
*
* Native `JSON.stringify` pre-processing is preserved before sorting:
* - `toJSON(key)` is invoked on objects that define it (own or
* inherited — covers `Date`, `URL`, and any user class). The `key`
* argument is the property name for nested object values, the array
* index as a string for array elements, and `''` for the top-level
* call, matching native semantics.
* - Boxed primitive wrappers (`new Number(...)`, `new String(...)`,
* `new Boolean(...)`) are unboxed to their primitive form.
* Both happen BEFORE the array/object branches dispatch, so the value
* actually walked is the post-conversion form. If `toJSON` returns
* `undefined`, the value is dropped from its parent (matching native
* `JSON.stringify`).
*
* Single-pass: `deepSort` walks the (possibly converted) value tree
* once, building a sorted clone. A `WeakSet` of ancestors tracks the
* current path through the object graph so that circular references
* throw `TypeError` (same contract as native `JSON.stringify`). The
* cycle check runs on the post-`toJSON` value, so a `toJSON` impl that
* returns an ancestor still throws. Ancestors are always removed in a
* `finally` block when unwinding out of each object branch (even on
* exception), so DAG inputs — where the same object is reachable via
* multiple keys — are handled correctly and do not throw.
*/
export function stableStringify(value: unknown, space?: number): string {
return JSON.stringify(deepSort(value, new WeakSet(), ''), null, space)
}
/**
* Returns a deep-sorted clone of the input: object keys lexicographic
* at every depth, arrays preserved. Useful when callers need to feed
* the sorted shape into a downstream serializer (e.g., when they must
* call `JSON.stringify` with a custom spacing or replacer).
*
* Applies the same `toJSON(key)` invocation and primitive-wrapper
* unboxing as `stableStringify`, so the returned shape mirrors what
* native `JSON.stringify` would have walked.
*/
export function sortKeysDeep<T>(value: T): T {
return deepSort(value, new WeakSet(), '') as T
}
function deepSort(
value: unknown,
ancestors: WeakSet<object>,
key: string,
): unknown {
// Step 1: invoke toJSON(key) if present — matches native pre-processing.
if (
value !== null &&
typeof value === 'object' &&
typeof (value as { toJSON?: unknown }).toJSON === 'function'
) {
value = (value as { toJSON: (k: string) => unknown }).toJSON(key)
}
// Step 2: unbox primitive wrappers.
if (value instanceof Number) value = Number(value)
else if (value instanceof String) value = String(value)
else if (value instanceof Boolean) value = Boolean(value.valueOf())
// Step 3: primitives short-circuit (post-toJSON the value may now be one).
if (value === null || typeof value !== 'object') return value
// Step 4: arrays — element key is the index as a string.
if (Array.isArray(value)) {
return value.map((v, i) => deepSort(v, ancestors, String(i)))
}
// Step 5: cycle check on the post-toJSON value.
if (ancestors.has(value as object)) {
throw new TypeError('Converting circular structure to JSON')
}
ancestors.add(value as object)
try {
const sorted: Record<string, unknown> = {}
for (const k of Object.keys(value as Record<string, unknown>).sort()) {
const child = deepSort(
(value as Record<string, unknown>)[k],
ancestors,
k,
)
if (child === undefined) continue
sorted[k] = child
}
return sorted
} finally {
ancestors.delete(value as object)
}
}

View File

@@ -1,10 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noImplicitAny": false,
"noEmit": true,
"allowImportingTsExtensions": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,