Compare commits
3 Commits
v0.7.0
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cce4b5afa4 | ||
|
|
6ea3eb6483 | ||
|
|
f699c1f2fc |
@@ -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
|
||||
{
|
||||
|
||||
19
bun.lock
19
bun.lock
@@ -28,6 +28,7 @@
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@vscode/ripgrep": "^1.17.1",
|
||||
"ajv": "8.18.0",
|
||||
"auto-bind": "5.0.1",
|
||||
"axios": "1.15.0",
|
||||
@@ -461,6 +462,8 @@
|
||||
|
||||
"@types/react": ["@types/react@19.2.14", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w=="],
|
||||
|
||||
"@vscode/ripgrep": ["@vscode/ripgrep@1.17.1", "", { "dependencies": { "https-proxy-agent": "^7.0.2", "proxy-from-env": "^1.1.0", "yauzl": "^2.9.2" } }, "sha512-xTs7DGyAO3IsJYOCTBP8LnTvPiYVKEuyv8s0xyJDBXfs8rhBfqnZPvb6xDT+RnwWzcXqW27xLS/aGrkjX7lNWw=="],
|
||||
|
||||
"accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
|
||||
|
||||
"agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
|
||||
@@ -491,6 +494,8 @@
|
||||
|
||||
"bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
|
||||
|
||||
"buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="],
|
||||
|
||||
"buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="],
|
||||
@@ -609,6 +614,8 @@
|
||||
|
||||
"fast-xml-parser": ["fast-xml-parser@5.5.8", "", { "dependencies": { "fast-xml-builder": "^1.1.4", "path-expression-matcher": "^1.2.0", "strnum": "^2.2.0" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-Z7Fh2nVQSb2d+poDViM063ix2ZGt9jmY1nWhPfHBOK2Hgnb/OW3P4Et3P/81SEej0J7QbWtJqxO05h8QYfK7LQ=="],
|
||||
|
||||
"fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="],
|
||||
|
||||
"fflate": ["fflate@0.8.2", "", {}, "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="],
|
||||
|
||||
"figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="],
|
||||
@@ -787,6 +794,8 @@
|
||||
|
||||
"path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="],
|
||||
|
||||
"pend": ["pend@1.2.0", "", {}, "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg=="],
|
||||
|
||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||
|
||||
"pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="],
|
||||
@@ -801,7 +810,7 @@
|
||||
|
||||
"proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
|
||||
|
||||
"proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
|
||||
|
||||
"qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="],
|
||||
|
||||
@@ -953,6 +962,8 @@
|
||||
|
||||
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
|
||||
|
||||
"yauzl": ["yauzl@2.10.0", "", { "dependencies": { "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } }, "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g=="],
|
||||
|
||||
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
|
||||
|
||||
"zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="],
|
||||
@@ -1369,6 +1380,8 @@
|
||||
|
||||
"@smithy/uuid/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||
|
||||
"axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"cli-highlight/chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
|
||||
|
||||
"cli-highlight/yargs": ["yargs@16.2.0", "", { "dependencies": { "cliui": "^7.0.2", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.0", "y18n": "^5.0.5", "yargs-parser": "^20.2.2" } }, "sha512-D1mvvtDG0L5ft/jGWkLpG1+m0eQxOfaBvTNELraWj22wSVUMWxZUvYgJYcKh6jGGIkJFhH4IZPQhR4TKpc8mBw=="],
|
||||
@@ -1429,6 +1442,8 @@
|
||||
|
||||
"@aws-sdk/nested-clients/@smithy/util-base64/@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="],
|
||||
|
||||
"@mendable/firecrawl-js/axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/core/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="],
|
||||
|
||||
"@opentelemetry/exporter-trace-otlp-grpc/@opentelemetry/otlp-transformer/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.57.2", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-uIX52NnTM0iBh84MShlpouI7UKqkZ7MrUszTmaypHBu4r7NofznSnQRfJ+uUeDtQDj6w8eFGg5KBLDAwAPz1+A=="],
|
||||
@@ -1509,6 +1524,8 @@
|
||||
|
||||
"cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
|
||||
|
||||
"firecrawl/axios/proxy-from-env": ["proxy-from-env@2.1.0", "", {}, "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="],
|
||||
|
||||
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
|
||||
|
||||
"qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="],
|
||||
|
||||
@@ -74,6 +74,7 @@
|
||||
"@opentelemetry/sdk-trace-base": "2.6.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.6.1",
|
||||
"@opentelemetry/semantic-conventions": "1.40.0",
|
||||
"@vscode/ripgrep": "^1.17.1",
|
||||
"ajv": "8.18.0",
|
||||
"auto-bind": "5.0.1",
|
||||
"axios": "1.15.0",
|
||||
|
||||
@@ -472,6 +472,11 @@ ${exports}
|
||||
'@aws-sdk/credential-providers',
|
||||
'@azure/identity',
|
||||
'google-auth-library',
|
||||
// @vscode/ripgrep ships a platform-specific binary alongside its
|
||||
// index.js and resolves the path via __dirname at runtime. Bundling
|
||||
// would freeze the build host's absolute path into dist/cli.mjs, so we
|
||||
// keep it external and rely on the npm package being installed.
|
||||
'@vscode/ripgrep',
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -5,16 +5,15 @@ import { resolveRipgrepConfig, wrapRipgrepUnavailableError } from './ripgrep.js'
|
||||
|
||||
const MOCK_BUILTIN_PATH = path.normalize(
|
||||
process.platform === 'win32'
|
||||
? `vendor/ripgrep/${process.arch}-win32/rg.exe`
|
||||
: `vendor/ripgrep/${process.arch}-${process.platform}/rg`,
|
||||
? `node_modules/@vscode/ripgrep/bin/rg.exe`
|
||||
: `node_modules/@vscode/ripgrep/bin/rg`,
|
||||
)
|
||||
|
||||
test('ripgrepCommand falls back to system rg when builtin binary is missing', () => {
|
||||
test('falls back to system rg when @vscode/ripgrep cannot be resolved', () => {
|
||||
const config = resolveRipgrepConfig({
|
||||
userWantsSystemRipgrep: false,
|
||||
bundledMode: false,
|
||||
builtinCommand: MOCK_BUILTIN_PATH,
|
||||
builtinExists: false,
|
||||
builtinCommand: null,
|
||||
systemExecutablePath: '/usr/bin/rg',
|
||||
processExecPath: '/fake/bun',
|
||||
})
|
||||
@@ -26,12 +25,11 @@ test('ripgrepCommand falls back to system rg when builtin binary is missing', ()
|
||||
})
|
||||
})
|
||||
|
||||
test('ripgrepCommand keeps builtin mode when bundled binary exists', () => {
|
||||
test('uses builtin @vscode/ripgrep path when the package resolves', () => {
|
||||
const config = resolveRipgrepConfig({
|
||||
userWantsSystemRipgrep: false,
|
||||
bundledMode: false,
|
||||
builtinCommand: MOCK_BUILTIN_PATH,
|
||||
builtinExists: true,
|
||||
systemExecutablePath: '/usr/bin/rg',
|
||||
processExecPath: '/fake/bun',
|
||||
})
|
||||
@@ -43,10 +41,59 @@ test('ripgrepCommand keeps builtin mode when bundled binary exists', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('honors USE_BUILTIN_RIPGREP=0 by selecting system rg even when builtin is available', () => {
|
||||
const config = resolveRipgrepConfig({
|
||||
userWantsSystemRipgrep: true,
|
||||
bundledMode: false,
|
||||
builtinCommand: MOCK_BUILTIN_PATH,
|
||||
systemExecutablePath: '/usr/bin/rg',
|
||||
processExecPath: '/fake/bun',
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
mode: 'system',
|
||||
command: 'rg',
|
||||
args: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('keeps embedded mode for Bun-compiled standalone executables', () => {
|
||||
const config = resolveRipgrepConfig({
|
||||
userWantsSystemRipgrep: false,
|
||||
bundledMode: true,
|
||||
builtinCommand: null,
|
||||
systemExecutablePath: '/usr/bin/rg',
|
||||
processExecPath: '/opt/openclaude/bin/openclaude',
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
mode: 'embedded',
|
||||
command: '/opt/openclaude/bin/openclaude',
|
||||
args: ['--no-config'],
|
||||
argv0: 'rg',
|
||||
})
|
||||
})
|
||||
|
||||
test('falls through to system rg as a last resort even when not on PATH', () => {
|
||||
const config = resolveRipgrepConfig({
|
||||
userWantsSystemRipgrep: false,
|
||||
bundledMode: false,
|
||||
builtinCommand: null,
|
||||
systemExecutablePath: 'rg',
|
||||
processExecPath: '/fake/bun',
|
||||
})
|
||||
|
||||
expect(config).toMatchObject({
|
||||
mode: 'system',
|
||||
command: 'rg',
|
||||
args: [],
|
||||
})
|
||||
})
|
||||
|
||||
test('wrapRipgrepUnavailableError explains missing packaged fallback', () => {
|
||||
const error = wrapRipgrepUnavailableError(
|
||||
{ code: 'ENOENT', message: 'spawn rg ENOENT' },
|
||||
{ mode: 'builtin', command: 'C:\\fake\\vendor\\ripgrep\\rg.exe', args: [] },
|
||||
{ mode: 'builtin', command: 'C:\\fake\\node_modules\\@vscode\\ripgrep\\bin\\rg.exe', args: [] },
|
||||
'win32',
|
||||
)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import memoize from 'lodash-es/memoize.js'
|
||||
import { homedir } from 'os'
|
||||
import * as path from 'path'
|
||||
import { logEvent } from 'src/services/analytics/index.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
import { isInBundledMode } from './bundledMode.js'
|
||||
import { logForDebugging } from './debug.js'
|
||||
import { isEnvDefinedFalsy } from './envUtils.js'
|
||||
@@ -15,13 +14,6 @@ import { logError } from './log.js'
|
||||
import { getPlatform } from './platform.js'
|
||||
import { countCharInString } from './stringUtils.js'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
// we use node:path.join instead of node:url.resolve because the former doesn't encode spaces
|
||||
const __dirname = path.join(
|
||||
__filename,
|
||||
process.env.NODE_ENV === 'test' ? '../../../' : '../',
|
||||
)
|
||||
|
||||
type RipgrepConfig = {
|
||||
mode: 'system' | 'builtin' | 'embedded'
|
||||
command: string
|
||||
@@ -35,11 +27,31 @@ function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||
return error instanceof Error
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the ripgrep binary path provided by the @vscode/ripgrep package.
|
||||
* The package downloads a platform/arch-specific binary at npm install time
|
||||
* (cached under the package's bin/ directory). Returns null when the package
|
||||
* cannot be resolved — for example when running as a Bun-compiled standalone
|
||||
* executable that doesn't ship node_modules.
|
||||
*/
|
||||
function resolveBuiltinRgPath(): string | null {
|
||||
try {
|
||||
// Lazy require so the resolution failure path stays graceful at import
|
||||
// time. The package only exports `rgPath`, so we do not need the rest.
|
||||
const mod = require('@vscode/ripgrep') as { rgPath?: string }
|
||||
if (mod.rgPath && existsSync(mod.rgPath)) {
|
||||
return mod.rgPath
|
||||
}
|
||||
} catch {
|
||||
// Falls through to null — caller decides the fallback.
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
type ResolveRipgrepConfigArgs = {
|
||||
userWantsSystemRipgrep: boolean
|
||||
bundledMode: boolean
|
||||
builtinCommand: string
|
||||
builtinExists: boolean
|
||||
builtinCommand: string | null
|
||||
systemExecutablePath: string
|
||||
processExecPath?: string
|
||||
}
|
||||
@@ -48,7 +60,6 @@ export function resolveRipgrepConfig({
|
||||
userWantsSystemRipgrep,
|
||||
bundledMode,
|
||||
builtinCommand,
|
||||
builtinExists,
|
||||
systemExecutablePath,
|
||||
processExecPath = process.execPath,
|
||||
}: ResolveRipgrepConfigArgs): RipgrepConfig {
|
||||
@@ -66,7 +77,7 @@ export function resolveRipgrepConfig({
|
||||
}
|
||||
}
|
||||
|
||||
if (builtinExists) {
|
||||
if (builtinCommand) {
|
||||
return { mode: 'builtin', command: builtinCommand, args: [] }
|
||||
}
|
||||
|
||||
@@ -74,7 +85,9 @@ export function resolveRipgrepConfig({
|
||||
return { mode: 'system', command: 'rg', args: [] }
|
||||
}
|
||||
|
||||
return { mode: 'builtin', command: builtinCommand, args: [] }
|
||||
// Last resort — leaves error reporting to the executor when no binary
|
||||
// can be located. wrapRipgrepUnavailableError() surfaces an install hint.
|
||||
return { mode: 'system', command: 'rg', args: [] }
|
||||
}
|
||||
|
||||
const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
@@ -82,19 +95,13 @@ const getRipgrepConfig = memoize((): RipgrepConfig => {
|
||||
process.env.USE_BUILTIN_RIPGREP,
|
||||
)
|
||||
const bundledMode = isInBundledMode()
|
||||
const rgRoot = path.resolve(__dirname, 'vendor', 'ripgrep')
|
||||
const builtinCommand =
|
||||
process.platform === 'win32'
|
||||
? path.resolve(rgRoot, `${process.arch}-win32`, 'rg.exe')
|
||||
: path.resolve(rgRoot, `${process.arch}-${process.platform}`, 'rg')
|
||||
const builtinExists = existsSync(builtinCommand)
|
||||
const builtinCommand = resolveBuiltinRgPath()
|
||||
const { cmd: systemExecutablePath } = findExecutable('rg', [])
|
||||
|
||||
return resolveRipgrepConfig({
|
||||
userWantsSystemRipgrep,
|
||||
bundledMode,
|
||||
builtinCommand,
|
||||
builtinExists,
|
||||
systemExecutablePath,
|
||||
})
|
||||
})
|
||||
|
||||
142
src/utils/serializationStability.test.ts
Normal file
142
src/utils/serializationStability.test.ts
Normal 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))
|
||||
})
|
||||
})
|
||||
199
src/utils/stableStringify.test.ts
Normal file
199
src/utils/stableStringify.test.ts
Normal 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'])
|
||||
})
|
||||
})
|
||||
132
src/utils/stableStringify.ts
Normal file
132
src/utils/stableStringify.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user