Compare commits

...

3 Commits

Author SHA1 Message Date
gnanam1990
cce4b5afa4 fix(ripgrep): use @vscode/ripgrep package as the builtin source (#911)
The vendored-binary lookup at vendor/ripgrep/<arch>-<platform>/rg never
resolved in this fork — that directory does not ship — so users without
a system rg had no working fallback. Switch to the @vscode/ripgrep
package so Microsoft maintains the platform/arch matrix and the binary
is delivered via npm.

- src/utils/ripgrep.ts: replace hand-rolled vendor-path resolution with
  rgPath from @vscode/ripgrep. Lazy require so a missing package falls
  through to the system rg branch instead of throwing at import.
  Drop builtinExists from the config args; builtinCommand is now a
  string-or-null. The system override (USE_BUILTIN_RIPGREP=0), the
  Bun-compiled standalone embedded mode, the macOS codesign hook, and
  all retry/timeout/error logic are preserved untouched.
- scripts/build.ts: mark @vscode/ripgrep as external. The package
  resolves rgPath via __dirname at runtime, so bundling would freeze
  the build host's absolute path into dist/cli.mjs.
- src/utils/ripgrep.test.ts: update for the new config shape and add
  tests covering USE_BUILTIN_RIPGREP=0, embedded mode, last-resort
  fallback, and null builtin path.

Tested locally on Linux (Bun 1.3.13). macOS (codesign hook) and
Windows (rg.exe extension) need contributor verification.
2026-04-28 09:21:07 +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
11 changed files with 598 additions and 35 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

@@ -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=="],

View File

@@ -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",

View File

@@ -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',
],
})

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

View File

@@ -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',
)

View File

@@ -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,
})
})

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)
}
}