@bitkyc08/opencodex 2.60.0 → 2.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS_INSTALL.md +64 -0
- package/README.md +28 -1
- package/bin/ocx.mjs +382 -209
- package/gui/dist/assets/App-CH6C5H7x.js +50 -0
- package/gui/dist/assets/Tray-CLZh48fM.js +1 -0
- package/gui/dist/assets/index-_bpvxJu0.css +1 -0
- package/gui/dist/assets/index-wpTOyepx.js +86 -0
- package/gui/dist/assets/usage-companion-chart-a0N58rRI.js +1 -0
- package/gui/dist/favicon.png +0 -0
- package/gui/dist/index.html +2 -2
- package/gui/dist/provider-icons/stepfun-color.svg +1 -0
- package/package.json +5 -1
- package/src/adapters/anthropic.ts +16 -0
- package/src/adapters/coding-agent/protocol.ts +36 -6
- package/src/adapters/coding-agent/turn.ts +10 -2
- package/src/adapters/command-code.ts +2 -1
- package/src/adapters/cursor/catalog.ts +51 -7
- package/src/adapters/cursor/protobuf-request.ts +6 -3
- package/src/adapters/cursor/request-builder.ts +13 -3
- package/src/adapters/cursor.ts +11 -2
- package/src/adapters/declaration-carrier.ts +45 -0
- package/src/adapters/devin.ts +75 -23
- package/src/adapters/google-antigravity-wire.ts +5 -2
- package/src/adapters/google-errors.ts +7 -1
- package/src/adapters/google.ts +29 -5
- package/src/adapters/image.ts +4 -1
- package/src/adapters/input-media-guard.ts +21 -9
- package/src/adapters/kiro/usage.ts +3 -2
- package/src/adapters/kiro-tool-fallback.ts +1 -1
- package/src/adapters/ollama-native.ts +6 -0
- package/src/adapters/openai-chat/developer-role.ts +61 -0
- package/src/adapters/openai-chat/messages.ts +46 -27
- package/src/adapters/openai-chat/parallel-tool-calls.ts +32 -0
- package/src/adapters/openai-chat/passthrough.ts +33 -9
- package/src/adapters/openai-chat/reasoning-wire.ts +89 -0
- package/src/adapters/openai-chat.ts +18 -57
- package/src/adapters/openai-responses/passthrough.ts +2 -0
- package/src/adapters/registry.ts +3 -2
- package/src/adapters/run-turn-queue.ts +178 -29
- package/src/adapters/xai-web-search.ts +16 -1
- package/src/bridge/errors.ts +8 -2
- package/src/bridge/response-json.ts +9 -1
- package/src/bridge/sse.ts +10 -0
- package/src/chat/inbound.ts +141 -5
- package/src/claude/desktop-3p.ts +7 -1
- package/src/claude/desktop-first-party.ts +183 -0
- package/src/claude/desktop-gateway-state.ts +41 -0
- package/src/claude/inbound-content-options.ts +6 -0
- package/src/claude/inbound.ts +32 -6
- package/src/claude/intercept/connect-proxy.ts +179 -0
- package/src/claude/intercept/listener.ts +122 -0
- package/src/claude/intercept/local-ca.ts +298 -0
- package/src/claude/intercept/runtime.ts +98 -0
- package/src/claude/intercept/settings.ts +189 -0
- package/src/cli/access.ts +87 -0
- package/src/cli/account-auth.ts +19 -0
- package/src/cli/capabilities.ts +31 -0
- package/src/cli/claude-desktop.ts +206 -16
- package/src/cli/codex-shim-autorestore.ts +3 -0
- package/src/cli/companion.ts +56 -0
- package/src/cli/dispatch.ts +43 -4
- package/src/cli/ensure-desired-integrations.ts +43 -5
- package/src/cli/help.ts +7 -9
- package/src/cli/index.ts +200 -61
- package/src/cli/init.ts +8 -0
- package/src/cli/integrations.ts +7 -1
- package/src/cli/registry.ts +41 -2
- package/src/cli/resolve.ts +230 -0
- package/src/cli/root.ts +24 -1
- package/src/cli/start-ownership-publication.ts +56 -0
- package/src/cli/status-probes.ts +2 -18
- package/src/cli/status.ts +62 -0
- package/src/cli/stop-report.ts +143 -0
- package/src/cli/uninstall-plan.ts +9 -0
- package/src/client/machine-listener.ts +2 -5
- package/src/clients/aside-profiles.ts +4 -0
- package/src/clients/config-export/zcode-store.ts +157 -0
- package/src/clients/config-export.ts +36 -0
- package/src/codex/app-server-processes.ts +72 -40
- package/src/codex/auth-api/login-flow.ts +6 -1
- package/src/codex/autostart-health.ts +28 -0
- package/src/codex/catalog/build-entries.ts +2 -2
- package/src/codex/catalog/effort.ts +3 -3
- package/src/codex/catalog/provider-models.ts +24 -15
- package/src/codex/catalog/retained-sync.ts +2 -2
- package/src/codex/convergence.ts +2 -2
- package/src/codex/history-provider.ts +12 -1
- package/src/codex/inject/config-toml.ts +41 -6
- package/src/codex/inject/paginated-openai-compat.ts +90 -0
- package/src/codex/inject.ts +18 -15
- package/src/codex/injected-marker.ts +18 -0
- package/src/codex/main-account.ts +6 -0
- package/src/codex/model-cache.ts +52 -6
- package/src/codex/model-entitlement-admission.ts +59 -0
- package/src/codex/model-entitlements.ts +87 -44
- package/src/codex/native-main-admission.ts +83 -0
- package/src/codex/routing/health-store.ts +39 -0
- package/src/codex/routing/selection.ts +37 -1
- package/src/codex/routing.ts +5 -41
- package/src/codex/shim-templates.ts +29 -3
- package/src/companion/settings.ts +132 -0
- package/src/config/atomic-write.ts +117 -5
- package/src/config/load-degrade.ts +34 -7
- package/src/config/process-state.ts +1 -1
- package/src/config/schema/config-schema.ts +27 -1
- package/src/config/schema/leaf-validators.ts +47 -0
- package/src/config.ts +1 -1
- package/src/generated/compatibility-version.json +418 -174
- package/src/integrations/config-io.ts +44 -10
- package/src/integrations/merge.ts +120 -13
- package/src/integrations/mutation-plan.ts +124 -18
- package/src/integrations/registry.ts +38 -0
- package/src/integrations/state.ts +78 -45
- package/src/integrations/target.ts +208 -0
- package/src/integrations/writer.ts +49 -11
- package/src/lab/conformance/fixture-provider.ts +5 -0
- package/src/lib/browser-launch-notice.ts +59 -0
- package/src/lib/bun-runtime.ts +6 -2
- package/src/lib/debug.ts +40 -0
- package/src/lib/open-url.ts +51 -7
- package/src/lib/package-tree-integrity.ts +2 -1
- package/src/lib/package-version.ts +8 -0
- package/src/lib/provider-egress.ts +310 -0
- package/src/lib/provider-outbound.ts +59 -14
- package/src/lib/proxy-env.ts +82 -7
- package/src/lib/request-execution-budget.ts +72 -0
- package/src/lib/request-failure-attribution.ts +183 -0
- package/src/lib/request-failure-model.ts +236 -0
- package/src/lib/request-resend-gate.ts +138 -0
- package/src/lib/standalone.ts +16 -0
- package/src/lib/upstream-retry.ts +167 -16
- package/src/lib/winsw.ts +2 -2
- package/src/oauth/index.ts +24 -1
- package/src/oauth/login-cli.ts +80 -29
- package/src/providers/api-key-resolve.ts +133 -0
- package/src/providers/api-key-selection.ts +5 -1
- package/src/providers/key-failover.ts +31 -1
- package/src/providers/key-store.ts +34 -110
- package/src/providers/model-rename-fields.ts +147 -0
- package/src/providers/model-rename-migration.ts +124 -37
- package/src/providers/quota/vendor-probes-key.ts +37 -22
- package/src/providers/reasoning-metadata.ts +43 -18
- package/src/providers/registry/entries-core.ts +9 -4
- package/src/providers/registry/entries-extended.ts +29 -4
- package/src/providers/registry/model-seeds.ts +47 -10
- package/src/providers/xai-transport.ts +12 -1
- package/src/reasoning-effort.ts +8 -0
- package/src/responses/function-call-compat.ts +38 -1
- package/src/responses/inline-document.ts +65 -0
- package/src/responses/input-media.ts +42 -8
- package/src/responses/muse-tool-name-alias.ts +19 -0
- package/src/responses/parser-content.ts +8 -2
- package/src/responses/parser-tools.ts +3 -0
- package/src/responses/parser.ts +3 -1
- package/src/responses/schema.ts +3 -0
- package/src/router.ts +17 -2
- package/src/server/admission-model-scope.ts +219 -0
- package/src/server/audio-live.ts +9 -3
- package/src/server/audio-upstream.ts +18 -0
- package/src/server/auth-cors.ts +26 -0
- package/src/server/chat-completions.ts +55 -2
- package/src/server/chat-native.ts +19 -4
- package/src/server/claude-messages.ts +55 -17
- package/src/server/grok-responses-snapshot-repair.ts +113 -11
- package/src/server/gui-freshness.ts +103 -0
- package/src/server/gui-static.ts +7 -9
- package/src/server/images.ts +59 -6
- package/src/server/index/claude-intercept-lifecycle.ts +49 -0
- package/src/server/index/serve-options.ts +56 -10
- package/src/server/index/spend-ledger-lifecycle.ts +34 -8
- package/src/server/index/startup-warnings.ts +24 -0
- package/src/server/index.ts +21 -28
- package/src/server/lifecycle.ts +4 -4
- package/src/server/live-call-bindings.ts +6 -0
- package/src/server/live.ts +88 -3
- package/src/server/management/agent-settings-routes.ts +121 -36
- package/src/server/management/companion-routes.ts +77 -0
- package/src/server/management/logs-usage-routes.ts +19 -0
- package/src/server/management/native-integration-routes.ts +103 -6
- package/src/server/management/oauth-account-routes.ts +45 -7
- package/src/server/management/route-registry.ts +6 -0
- package/src/server/management/shared.ts +18 -1
- package/src/server/management/usage-timeline-routes.ts +44 -0
- package/src/server/management-api.ts +8 -9
- package/src/server/proxy-liveness.ts +75 -0
- package/src/server/relay.ts +19 -2
- package/src/server/request-log-failure-attribution.ts +99 -0
- package/src/server/request-log.ts +114 -0
- package/src/server/request-metrics.ts +92 -30
- package/src/server/responses/codex-ws-wire.ts +34 -8
- package/src/server/responses/combo-stream-preflight.ts +168 -6
- package/src/server/responses/compact.ts +11 -0
- package/src/server/responses/core-opaque-recovery.ts +90 -0
- package/src/server/responses/fetch-helpers.ts +124 -8
- package/src/server/responses/input-admission.ts +10 -0
- package/src/server/responses/passthrough-delivery.ts +14 -1
- package/src/server/responses/passthrough-dispatch.ts +179 -35
- package/src/server/responses/passthrough-error.ts +27 -8
- package/src/server/responses/request-prepare.ts +42 -1
- package/src/server/responses/request-send-budget.ts +12 -0
- package/src/server/responses/request-transport.ts +24 -4
- package/src/server/responses/reset-replay.ts +108 -0
- package/src/server/responses-request-tool-scope.ts +214 -0
- package/src/server/responses-undeclared-tool-guard.ts +4 -1
- package/src/server/search.ts +25 -1
- package/src/server/usage-ledger-retention.ts +73 -0
- package/src/service/cli.ts +48 -2
- package/src/service/health.ts +3 -2
- package/src/service/install-state-contract.d.mts +27 -0
- package/src/service/install-state-contract.mjs +34 -0
- package/src/service/launchd.ts +1 -1
- package/src/service/orchestration.ts +2 -4
- package/src/service/ownership-compatibility.ts +164 -0
- package/src/service/ownership-mutation-lease.d.mts +32 -0
- package/src/service/ownership-mutation-lease.mjs +211 -0
- package/src/service/repair.ts +45 -1
- package/src/service/state-lock.ts +269 -0
- package/src/service/state-record.d.mts +36 -0
- package/src/service/state-record.mjs +138 -0
- package/src/service/state.ts +582 -68
- package/src/service/windows-taskxml.ts +11 -10
- package/src/service.ts +7 -3
- package/src/tray/windows-tray.ps1 +1 -1
- package/src/types/config.ts +37 -0
- package/src/types/provider.ts +73 -0
- package/src/types/request.ts +28 -2
- package/src/types/tools.ts +19 -0
- package/src/types.ts +3 -0
- package/src/update/index.ts +207 -63
- package/src/update/job.ts +9 -5
- package/src/update/ownership-transaction.ts +47 -0
- package/src/update/restart-ownership.ts +54 -0
- package/src/update/runtime-ownership.d.mts +40 -0
- package/src/update/runtime-ownership.mjs +122 -0
- package/src/usage/attempt-delivery.ts +198 -0
- package/src/usage/cache-diagnostic.ts +305 -0
- package/src/usage/failure-fingerprint.ts +118 -0
- package/src/usage/failure-projection-cache.ts +174 -0
- package/src/usage/failure-projection.ts +174 -0
- package/src/usage/ledger-retention.ts +165 -0
- package/src/usage/log.ts +126 -79
- package/src/usage/request-outcome.ts +150 -0
- package/src/usage/retention-contract.ts +28 -0
- package/src/usage/summary.ts +2 -2
- package/src/usage/telemetry-contract.ts +237 -0
- package/src/usage/timeline.ts +236 -0
- package/src/web-search/alpha-search.ts +21 -1
- package/gui/dist/assets/index-BTuCbqQd.css +0 -1
- package/gui/dist/assets/index-DoBVdPHP.js +0 -134
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Does an update own the runtime it is about to stop and restart?
|
|
3
|
+
*
|
|
4
|
+
* Both updaters ask this: `src/update/index.ts` on the Bun path and `bin/ocx.mjs` on the
|
|
5
|
+
* npm and pnpm path. It lives here as plain ESM for the same reason `stop-decision.mjs`
|
|
6
|
+
* does — the Node launcher has to be able to import it, and two lanes deciding the same
|
|
7
|
+
* situation separately is how a fix ships on one side only.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Decide how an update treats a runtime it may not own.
|
|
12
|
+
*
|
|
13
|
+
* `ocx update` replaces the package's files and then puts the proxy back: it stops the
|
|
14
|
+
* running server first, and afterwards runs `ocx service repair` to re-register and restart
|
|
15
|
+
* the background service. Under a desktop owner both halves are wrong. The running server is
|
|
16
|
+
* the app's own bundled sidecar rather than anything this package installed, so stopping it
|
|
17
|
+
* takes down a runtime the update has no way to bring back; and the repair would re-enable
|
|
18
|
+
* the npm launcher the takeover superseded, which is the exact reactivation the ownership
|
|
19
|
+
* marker exists to prevent. Neither half is needed either — the app updates its own runtime.
|
|
20
|
+
*
|
|
21
|
+
* The service registration itself is untouched in every case. It is kept by decision, not by
|
|
22
|
+
* accident, so a user who later runs `ocx service install` gets their npm service back.
|
|
23
|
+
*
|
|
24
|
+
* THE COST OF A STALE MARKER. This reads the recorded claim, not liveness. An app deleted
|
|
25
|
+
* without releasing ownership leaves a marker behind, and an update then declines to stop or
|
|
26
|
+
* refresh a runtime no app is managing any more. That is the orphan-recovery cost the
|
|
27
|
+
* two-record ownership design accepted; `ocx service install` clears the marker and restores
|
|
28
|
+
* the ordinary path.
|
|
29
|
+
*
|
|
30
|
+
* The three returned flags are separate authorities, not commands. In particular, leaving
|
|
31
|
+
* a runtime running is not permission to replace the package it may be executing from.
|
|
32
|
+
*
|
|
33
|
+
* A recorded desktop claim does not prove which binary is live. Until the bundled resolver
|
|
34
|
+
* carries installation identity, it therefore blocks package replacement as well as stop and
|
|
35
|
+
* restoration; the notice tells a stale-marker user how to take ownership back explicitly.
|
|
36
|
+
*
|
|
37
|
+
* @param {{ ownership: { owner: string, installId: string, consentGeneration: number } | null, ownershipUnknown?: boolean, serviceInstalled: boolean }} input
|
|
38
|
+
* @returns {{ mayReplacePackage: boolean, mayStopRuntime: boolean, mayRestoreService: boolean, notice: string | null }}
|
|
39
|
+
*/
|
|
40
|
+
export function planUpdateRuntimeHandling({ ownership, ownershipUnknown = false, serviceInstalled }) {
|
|
41
|
+
// Unreadable, malformed or contradictory is not "nobody owns it". Reading it that way is
|
|
42
|
+
// how a permissions error reactivates the npm launcher over a consented takeover.
|
|
43
|
+
if (ownershipUnknown) {
|
|
44
|
+
return {
|
|
45
|
+
mayReplacePackage: false,
|
|
46
|
+
mayStopRuntime: false,
|
|
47
|
+
mayRestoreService: false,
|
|
48
|
+
notice: "⚠️ The background runtime's recorded owner could not be determined, so it was "
|
|
49
|
+
+ "left running and the service registration was not touched. "
|
|
50
|
+
+ "Run 'ocx service install' to re-register the service and take the runtime back.",
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
// Any owner that is not this CLI. Reading it this way rather than testing for "desktop"
|
|
54
|
+
// keeps a third kind of owner from silently falling into the branch that touches the npm
|
|
55
|
+
// registration.
|
|
56
|
+
if (ownership && ownership.owner !== "cli") {
|
|
57
|
+
return {
|
|
58
|
+
// A claim does not prove which binary is live. A stale desktop marker beside a
|
|
59
|
+
// manually started npm proxy would otherwise replace that proxy's executing files.
|
|
60
|
+
mayReplacePackage: false,
|
|
61
|
+
mayStopRuntime: false,
|
|
62
|
+
mayRestoreService: false,
|
|
63
|
+
notice: `🖥️ The desktop app owns the background runtime (install ${ownership.installId}, `
|
|
64
|
+
+ `consent generation ${ownership.consentGeneration}). It and the npm package were left unchanged, and the `
|
|
65
|
+
+ "service registration was neither re-enabled nor restarted. "
|
|
66
|
+
+ "If the desktop app is gone, run 'ocx service install' to take the runtime back.",
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
mayReplacePackage: true,
|
|
71
|
+
mayStopRuntime: true,
|
|
72
|
+
mayRestoreService: serviceInstalled,
|
|
73
|
+
notice: null,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Decide recovery after this updater already stopped the prior CLI-owned runtime. */
|
|
78
|
+
export function planStoppedRuntimeRecovery({
|
|
79
|
+
stopAttempted,
|
|
80
|
+
ownership,
|
|
81
|
+
ownershipUnknown = false,
|
|
82
|
+
sameOwner,
|
|
83
|
+
liveness,
|
|
84
|
+
serviceInstalled,
|
|
85
|
+
launcherUsable,
|
|
86
|
+
hadRuntimeState,
|
|
87
|
+
}) {
|
|
88
|
+
if (!stopAttempted) return { action: "none", reason: "not-stopped" };
|
|
89
|
+
if (ownershipUnknown) return { action: "manual", reason: "ownership-unknown" };
|
|
90
|
+
if (!sameOwner || (ownership && ownership.owner !== "cli")) {
|
|
91
|
+
return { action: "none", reason: "ownership-transferred" };
|
|
92
|
+
}
|
|
93
|
+
if (liveness !== "dead") return { action: "manual", reason: `runtime-${liveness}` };
|
|
94
|
+
if (!launcherUsable) return { action: "manual", reason: "launcher-unavailable" };
|
|
95
|
+
if (serviceInstalled) return { action: "service", reason: "same-cli-owner" };
|
|
96
|
+
if (hadRuntimeState) return { action: "direct", reason: "same-cli-owner" };
|
|
97
|
+
return { action: "none", reason: "nothing-to-restore" };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Re-read the current package runtime before probing. The result keeps an absent current
|
|
102
|
+
* record distinct from a dead captured endpoint while still projecting one fail-closed
|
|
103
|
+
* liveness verdict for replacement and recovery decisions.
|
|
104
|
+
*/
|
|
105
|
+
export function inspectPackageRuntimeLiveness({ capturedTarget, readCurrentTarget, probe }) {
|
|
106
|
+
const currentTarget = readCurrentTarget();
|
|
107
|
+
const observations = new Map();
|
|
108
|
+
const inspect = target => {
|
|
109
|
+
const key = `${target.hostname}:${target.port}`;
|
|
110
|
+
if (!observations.has(key)) observations.set(key, probe(target));
|
|
111
|
+
return observations.get(key);
|
|
112
|
+
};
|
|
113
|
+
// Probe the fresh record first. It is the address a replacement runtime may have
|
|
114
|
+
// published while the updater was waiting on the ownership lease.
|
|
115
|
+
const current = currentTarget.kind === "target" ? inspect(currentTarget.target) : currentTarget.kind;
|
|
116
|
+
const captured = inspect(capturedTarget);
|
|
117
|
+
const verdicts = current === "absent" ? [captured] : [current, captured];
|
|
118
|
+
const overall = verdicts.includes("live")
|
|
119
|
+
? "live"
|
|
120
|
+
: verdicts.includes("unknown") ? "unknown" : "dead";
|
|
121
|
+
return { current, captured, overall };
|
|
122
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Counting what an attempt delivered, without recording what it said.
|
|
3
|
+
*
|
|
4
|
+
* The recorder below is bound to a request-scoped object and reaches the CURRENT attempt through
|
|
5
|
+
* a callback rather than holding one. An attempt can be rotated mid-request -- a key-account
|
|
6
|
+
* change seals the old one and starts a fresh one -- and a recorder holding a reference would
|
|
7
|
+
* keep crediting frames to an attempt that had already been finalized and snapshotted.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here reads a payload's content. `semanticBytes` is a length; the event classification
|
|
10
|
+
* reads only a frame's type name and an item's type name, both of which are protocol constants.
|
|
11
|
+
*/
|
|
12
|
+
import type { AttemptDeliverySummary } from "./telemetry-contract";
|
|
13
|
+
|
|
14
|
+
export interface AttemptDeliveryTarget {
|
|
15
|
+
deliverySummary?: AttemptDeliverySummary;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RelayedEventObservation {
|
|
19
|
+
semanticBytes?: number;
|
|
20
|
+
sideEffect?: boolean;
|
|
21
|
+
terminal?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface AttemptDeliveryRecorder {
|
|
25
|
+
noteAdapterEvent(): void;
|
|
26
|
+
noteRelayedEvent(observation?: RelayedEventObservation): void;
|
|
27
|
+
noteBufferedDelivery(body: Record<string, unknown>): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createAttemptDeliverySummary(): AttemptDeliverySummary {
|
|
31
|
+
return { adapterEvents: 0, relayedEvents: 0, semanticBytes: 0, sideEffectEvents: 0, terminalEvents: 0 };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Saturating addition.
|
|
36
|
+
*
|
|
37
|
+
* A counter that wraps or drifts into a non-integer is worse than one that stops: the row would
|
|
38
|
+
* be dropped by the normalizer and the whole summary lost. A long-lived stream that somehow
|
|
39
|
+
* reaches the safe-integer ceiling keeps a readable, if pinned, number.
|
|
40
|
+
*/
|
|
41
|
+
function bump(current: number, by: number): number {
|
|
42
|
+
if (!Number.isFinite(by) || by <= 0) return current;
|
|
43
|
+
return Math.min(Number.MAX_SAFE_INTEGER, current + Math.floor(by));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SEMANTIC_DELTA_EVENTS: ReadonlySet<string> = new Set([
|
|
47
|
+
"response.output_text.delta",
|
|
48
|
+
"response.reasoning_summary_text.delta",
|
|
49
|
+
"response.reasoning_text.delta",
|
|
50
|
+
"response.function_call_arguments.delta",
|
|
51
|
+
"response.custom_tool_call_input.delta",
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const TERMINAL_EVENTS: ReadonlySet<string> = new Set([
|
|
55
|
+
"response.completed",
|
|
56
|
+
"response.incomplete",
|
|
57
|
+
"response.failed",
|
|
58
|
+
]);
|
|
59
|
+
|
|
60
|
+
const SIDE_EFFECT_ITEM_TYPES: ReadonlySet<string> = new Set([
|
|
61
|
+
"function_call",
|
|
62
|
+
"custom_tool_call",
|
|
63
|
+
"web_search_call",
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* What one relayed frame contributes, read from its type name alone.
|
|
68
|
+
*
|
|
69
|
+
* A side effect is counted when the item STARTS, not on its argument fragments and not again on
|
|
70
|
+
* the matching done frame, so one tool call is one effect however many deltas carried its
|
|
71
|
+
* arguments.
|
|
72
|
+
*/
|
|
73
|
+
export function classifyRelayedResponseEvent(
|
|
74
|
+
name: string,
|
|
75
|
+
data: Record<string, unknown>,
|
|
76
|
+
): RelayedEventObservation {
|
|
77
|
+
const observation: RelayedEventObservation = {};
|
|
78
|
+
if (SEMANTIC_DELTA_EVENTS.has(name) && typeof data.delta === "string") {
|
|
79
|
+
observation.semanticBytes = Buffer.byteLength(data.delta, "utf8");
|
|
80
|
+
}
|
|
81
|
+
if (name === "response.output_item.added") {
|
|
82
|
+
const item = data.item;
|
|
83
|
+
const type = item !== null && typeof item === "object"
|
|
84
|
+
? (item as Record<string, unknown>).type
|
|
85
|
+
: undefined;
|
|
86
|
+
if (typeof type === "string" && SIDE_EFFECT_ITEM_TYPES.has(type)) observation.sideEffect = true;
|
|
87
|
+
}
|
|
88
|
+
if (TERMINAL_EVENTS.has(name)) observation.terminal = true;
|
|
89
|
+
return observation;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* What one buffered response body delivered.
|
|
94
|
+
*
|
|
95
|
+
* A non-streaming turn has no frames: the whole answer reaches the client as one JSON body. Read
|
|
96
|
+
* naively that looks like total relay loss -- adapter events counted, nothing relayed -- which is
|
|
97
|
+
* precisely the signal these counters exist to raise, so a buffered response would raise it on
|
|
98
|
+
* every request and make it worthless. Everything the adapter produced DID reach the client here;
|
|
99
|
+
* it arrived in one piece. So the relayed total is set to the adapter total rather than left at
|
|
100
|
+
* zero, and the semantic facts are read from the body that was built.
|
|
101
|
+
*
|
|
102
|
+
* Fields are read defensively and by name. Keying this on the adapter event union would make a
|
|
103
|
+
* member added later a merge-time exhaustiveness failure in a counter that does not need one.
|
|
104
|
+
*/
|
|
105
|
+
function observeBufferedBody(body: Record<string, unknown>): { semanticBytes: number; sideEffects: number } {
|
|
106
|
+
const output = Array.isArray(body.output) ? body.output : [];
|
|
107
|
+
let semanticBytes = 0;
|
|
108
|
+
let sideEffects = 0;
|
|
109
|
+
for (const entry of output) {
|
|
110
|
+
if (entry === null || typeof entry !== "object") continue;
|
|
111
|
+
const item = entry as Record<string, unknown>;
|
|
112
|
+
if (typeof item.type === "string" && SIDE_EFFECT_ITEM_TYPES.has(item.type)) sideEffects += 1;
|
|
113
|
+
if (typeof item.arguments === "string") semanticBytes += Buffer.byteLength(item.arguments, "utf8");
|
|
114
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
115
|
+
for (const part of content) {
|
|
116
|
+
if (part === null || typeof part !== "object") continue;
|
|
117
|
+
const text = (part as Record<string, unknown>).text;
|
|
118
|
+
if (typeof text === "string") semanticBytes += Buffer.byteLength(text, "utf8");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return { semanticBytes, sideEffects };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const recordersByScope = new WeakMap<object, AttemptDeliveryRecorder>();
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Bind a recorder to a request-scoped object.
|
|
128
|
+
*
|
|
129
|
+
* The scope is the request's translator budget, which every bridge on the delivery path already
|
|
130
|
+
* receives. Reusing it avoids threading a new parameter through six call sites where any one of
|
|
131
|
+
* them silently defaulting would leave a transport uncounted -- the failure mode that made
|
|
132
|
+
* `locallyAnswered` travel on the attempt instead of as an argument.
|
|
133
|
+
*/
|
|
134
|
+
export function bindAttemptDeliveryRecorder(
|
|
135
|
+
scope: object,
|
|
136
|
+
currentAttempt: () => AttemptDeliveryTarget | undefined,
|
|
137
|
+
): AttemptDeliveryRecorder {
|
|
138
|
+
const summaryFor = (): AttemptDeliverySummary | undefined => {
|
|
139
|
+
const attempt = currentAttempt();
|
|
140
|
+
if (!attempt) return undefined;
|
|
141
|
+
return attempt.deliverySummary ??= createAttemptDeliverySummary();
|
|
142
|
+
};
|
|
143
|
+
const recorder: AttemptDeliveryRecorder = {
|
|
144
|
+
noteAdapterEvent(): void {
|
|
145
|
+
const summary = summaryFor();
|
|
146
|
+
if (summary) summary.adapterEvents = bump(summary.adapterEvents, 1);
|
|
147
|
+
},
|
|
148
|
+
noteRelayedEvent(observation): void {
|
|
149
|
+
const summary = summaryFor();
|
|
150
|
+
if (!summary) return;
|
|
151
|
+
summary.relayedEvents = bump(summary.relayedEvents, 1);
|
|
152
|
+
if (observation?.semanticBytes) summary.semanticBytes = bump(summary.semanticBytes, observation.semanticBytes);
|
|
153
|
+
if (observation?.sideEffect) summary.sideEffectEvents = bump(summary.sideEffectEvents, 1);
|
|
154
|
+
if (observation?.terminal) summary.terminalEvents = bump(summary.terminalEvents, 1);
|
|
155
|
+
},
|
|
156
|
+
noteBufferedDelivery(body): void {
|
|
157
|
+
const summary = summaryFor();
|
|
158
|
+
if (!summary) return;
|
|
159
|
+
const observed = observeBufferedBody(body);
|
|
160
|
+
summary.relayedEvents = Math.max(summary.relayedEvents, summary.adapterEvents);
|
|
161
|
+
summary.semanticBytes = bump(summary.semanticBytes, observed.semanticBytes);
|
|
162
|
+
summary.sideEffectEvents = bump(summary.sideEffectEvents, observed.sideEffects);
|
|
163
|
+
summary.terminalEvents = bump(summary.terminalEvents, 1);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
recordersByScope.set(scope, recorder);
|
|
167
|
+
return recorder;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function attemptDeliveryRecorder(scope: object | undefined): AttemptDeliveryRecorder | undefined {
|
|
171
|
+
return scope ? recordersByScope.get(scope) : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* A persisted summary is trusted only when all five counts are non-negative safe integers.
|
|
176
|
+
*
|
|
177
|
+
* The whole record is dropped rather than repaired: a partially trusted count is a number an
|
|
178
|
+
* operator would compare against another number, and half a summary is how a loss signal turns
|
|
179
|
+
* into a false one.
|
|
180
|
+
*/
|
|
181
|
+
export function normalizeAttemptDeliverySummary(value: unknown): AttemptDeliverySummary | undefined {
|
|
182
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
183
|
+
const raw = value as Record<string, unknown>;
|
|
184
|
+
const counts = createAttemptDeliverySummary();
|
|
185
|
+
for (const key of Object.keys(counts) as Array<keyof AttemptDeliverySummary>) {
|
|
186
|
+
const count = raw[key];
|
|
187
|
+
if (typeof count !== "number" || !Number.isSafeInteger(count) || count < 0) return undefined;
|
|
188
|
+
counts[key] = count;
|
|
189
|
+
}
|
|
190
|
+
return counts;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** A detached copy, so a snapshotted attempt cannot keep counting after it was finalized. */
|
|
194
|
+
export function cloneAttemptDeliverySummary(
|
|
195
|
+
summary: AttemptDeliverySummary | undefined,
|
|
196
|
+
): AttemptDeliverySummary | undefined {
|
|
197
|
+
return summary ? { ...summary } : undefined;
|
|
198
|
+
}
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
/** Privacy-bounded, process-local cache diagnostics. Enable with OPENCODEX_CACHE_DEBUG=1. */
|
|
2
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
3
|
+
import { appendFileSync, chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { CODEX_AFFINITY_DEBUG_SAFE_HEADERS } from "../codex/affinity-debug";
|
|
6
|
+
import { getConfigDir } from "../config";
|
|
7
|
+
import { recordOwnedConfigPath } from "../lib/config-ownership";
|
|
8
|
+
import type { CacheTelemetryProvenance } from "./log";
|
|
9
|
+
|
|
10
|
+
const CACHE_DEBUG_KEY = randomBytes(32);
|
|
11
|
+
const MAX_BLOCKS = 128;
|
|
12
|
+
const MAX_DRAFTS = 512;
|
|
13
|
+
export const CACHE_DEBUG_MAX_LINES = 200;
|
|
14
|
+
export const CACHE_DEBUG_KEEP_LINES = 100;
|
|
15
|
+
|
|
16
|
+
export type PromptCacheKeySource = "caller" | "metadata-derived" | "system-derived" | "proxy-synthesized";
|
|
17
|
+
export interface TaggedPresence { present: boolean; tag?: string; source?: PromptCacheKeySource }
|
|
18
|
+
export interface TaggedSequence { present: boolean; count: number; tags: string[]; truncated?: true }
|
|
19
|
+
export interface PrefixFingerprint {
|
|
20
|
+
instructions: TaggedSequence;
|
|
21
|
+
tools: TaggedSequence;
|
|
22
|
+
messages: TaggedSequence;
|
|
23
|
+
}
|
|
24
|
+
export interface CacheDiagnosticDraft {
|
|
25
|
+
promptCacheKey?: { inbound?: TaggedPresence; outbound?: TaggedPresence };
|
|
26
|
+
session?: { inboundHeader?: TaggedPresence; outboundHeader?: TaggedPresence };
|
|
27
|
+
prefix?: { inbound?: PrefixFingerprint; outbound?: PrefixFingerprint };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface CacheDiagnosticFinalFacts {
|
|
31
|
+
requestId: string;
|
|
32
|
+
logicalRequestId?: string;
|
|
33
|
+
protocol: "responses" | "chat" | "messages";
|
|
34
|
+
provider: string;
|
|
35
|
+
model: string;
|
|
36
|
+
accountLogLabel?: string;
|
|
37
|
+
affinityMove?: string;
|
|
38
|
+
affinityReason?: string;
|
|
39
|
+
/**
|
|
40
|
+
* The cache counter exactly as the upstream usage object carried it, read before any
|
|
41
|
+
* client-wire defaulting. Undefined means the upstream object had no cache counter at
|
|
42
|
+
* all, which keeps a measured zero distinct from an absent-then-defaulted zero.
|
|
43
|
+
*/
|
|
44
|
+
rawCacheCounterValue?: number;
|
|
45
|
+
normalizedCacheValue?: number;
|
|
46
|
+
cacheProvenance: CacheTelemetryProvenance;
|
|
47
|
+
draft?: CacheDiagnosticDraft;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const drafts = new Map<string, CacheDiagnosticDraft>();
|
|
51
|
+
const bodyDrafts = new WeakMap<object, CacheDiagnosticDraft>();
|
|
52
|
+
|
|
53
|
+
export function isCacheDiagnosticEnabled(): boolean {
|
|
54
|
+
return process.env.OPENCODEX_CACHE_DEBUG === "1";
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function cacheDiagnosticPath(): string {
|
|
58
|
+
return join(getConfigDir(), "cache-debug.jsonl");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function tag(domain: string, value: string): string {
|
|
62
|
+
return createHmac("sha256", CACHE_DEBUG_KEY)
|
|
63
|
+
.update(domain).update("\0").update(value).digest("hex").slice(0, 12);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function tagCacheDiagnosticValue(domain: string, value: string): string {
|
|
67
|
+
return tag(`cache-debug:${domain}`, value);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function canonicalJson(value: unknown): string {
|
|
71
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
72
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
73
|
+
const record = value as Record<string, unknown>;
|
|
74
|
+
return `{${Object.keys(record).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function taggedPresence(value: unknown, domain: string, source?: PromptCacheKeySource): TaggedPresence {
|
|
78
|
+
if (typeof value !== "string" || value.length === 0) return { present: false };
|
|
79
|
+
return { present: true, tag: tag(domain, value), ...(source ? { source } : {}) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function sessionPresence(headers: Headers | HeadersInit): TaggedPresence {
|
|
83
|
+
const normalized = headers instanceof Headers ? headers : new Headers(headers);
|
|
84
|
+
const values = CODEX_AFFINITY_DEBUG_SAFE_HEADERS.flatMap(name => {
|
|
85
|
+
const value = normalized.get(name);
|
|
86
|
+
return value === null ? [] : [[name, value] as const];
|
|
87
|
+
});
|
|
88
|
+
return values.length === 0
|
|
89
|
+
? { present: false }
|
|
90
|
+
: { present: true, tag: tag("cache-debug:session-headers", canonicalJson(values)) };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function sequence(blocks: unknown[], domain: string): TaggedSequence {
|
|
94
|
+
const bounded = blocks.slice(0, MAX_BLOCKS);
|
|
95
|
+
return {
|
|
96
|
+
present: blocks.length > 0,
|
|
97
|
+
count: blocks.length,
|
|
98
|
+
tags: bounded.map(block => tag(domain, canonicalJson(block))),
|
|
99
|
+
...(blocks.length > MAX_BLOCKS ? { truncated: true as const } : {}),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function requestBlocks(body: Record<string, unknown>): { instructions: unknown[]; messages: unknown[] } {
|
|
104
|
+
const instructions = body.instructions;
|
|
105
|
+
// The spread is load-bearing: an array-valued instructions field must be copied, never
|
|
106
|
+
// aliased, because the pushes below would otherwise mutate the live request body that
|
|
107
|
+
// the adapter is about to serialize upstream.
|
|
108
|
+
const instructionRows = instructions === undefined || instructions === null
|
|
109
|
+
? [] : Array.isArray(instructions) ? [...instructions] : [instructions];
|
|
110
|
+
const input = Array.isArray(body.input) ? body.input : Array.isArray(body.messages) ? body.messages : [];
|
|
111
|
+
const messages: unknown[] = [];
|
|
112
|
+
for (const block of input) {
|
|
113
|
+
if (block && typeof block === "object" && !Array.isArray(block)) {
|
|
114
|
+
const row = block as Record<string, unknown>;
|
|
115
|
+
if (row.role === "system" || row.role === "developer") {
|
|
116
|
+
const content = row.content;
|
|
117
|
+
instructionRows.push(...(Array.isArray(content) ? content : [content]));
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
messages.push(block);
|
|
122
|
+
}
|
|
123
|
+
return { instructions: instructionRows, messages };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function prefixFingerprint(body: unknown): PrefixFingerprint {
|
|
127
|
+
const record = body && typeof body === "object" && !Array.isArray(body)
|
|
128
|
+
? body as Record<string, unknown> : {};
|
|
129
|
+
const tools = Array.isArray(record.tools) ? record.tools : [];
|
|
130
|
+
const blocks = requestBlocks(record);
|
|
131
|
+
return {
|
|
132
|
+
instructions: sequence(blocks.instructions, "cache-debug:prefix:instructions"),
|
|
133
|
+
tools: sequence(tools, "cache-debug:prefix:tools"),
|
|
134
|
+
messages: sequence(blocks.messages, "cache-debug:prefix:messages"),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function firstDivergence(inbound: PrefixFingerprint, outbound: PrefixFingerprint):
|
|
139
|
+
{ section: "instructions" | "tools" | "messages"; index: number } | undefined {
|
|
140
|
+
for (const section of ["instructions", "tools", "messages"] as const) {
|
|
141
|
+
const before = inbound[section].tags;
|
|
142
|
+
const after = outbound[section].tags;
|
|
143
|
+
const compared = Math.min(before.length, after.length);
|
|
144
|
+
for (let index = 0; index < compared; index += 1) {
|
|
145
|
+
if (before[index] !== after[index]) return { section, index };
|
|
146
|
+
}
|
|
147
|
+
if (inbound[section].count !== outbound[section].count) return { section, index: compared };
|
|
148
|
+
}
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function observe(requestId: string, observation: Partial<CacheDiagnosticDraft>): CacheDiagnosticDraft {
|
|
153
|
+
const draft = drafts.get(requestId) ?? {};
|
|
154
|
+
Object.assign(draft, observation);
|
|
155
|
+
drafts.delete(requestId);
|
|
156
|
+
drafts.set(requestId, draft);
|
|
157
|
+
while (drafts.size > MAX_DRAFTS) drafts.delete(drafts.keys().next().value!);
|
|
158
|
+
return draft;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function observeInbound(
|
|
162
|
+
body: unknown,
|
|
163
|
+
headers: Headers,
|
|
164
|
+
source: PromptCacheKeySource = "caller",
|
|
165
|
+
): CacheDiagnosticDraft {
|
|
166
|
+
if (!isCacheDiagnosticEnabled()) return {};
|
|
167
|
+
try {
|
|
168
|
+
const record = body && typeof body === "object" && !Array.isArray(body)
|
|
169
|
+
? body as Record<string, unknown> : {};
|
|
170
|
+
const draft: CacheDiagnosticDraft = {
|
|
171
|
+
promptCacheKey: { inbound: taggedPresence(record.prompt_cache_key, "cache-debug:prompt-cache-key", source) },
|
|
172
|
+
session: { inboundHeader: sessionPresence(headers) },
|
|
173
|
+
prefix: { inbound: prefixFingerprint(body) },
|
|
174
|
+
};
|
|
175
|
+
if (body && typeof body === "object") bodyDrafts.set(body, draft);
|
|
176
|
+
return draft;
|
|
177
|
+
} catch {
|
|
178
|
+
return {};
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Alias a later form of the same request body (for example after previous-response
|
|
184
|
+
* expansion) to an existing draft, so the outbound observation at the adapter seam can
|
|
185
|
+
* find it. The inbound fingerprint intentionally stays the literal pre-expansion body.
|
|
186
|
+
*/
|
|
187
|
+
export function rebindCacheDiagnosticBodyAlias(body: unknown, draft: CacheDiagnosticDraft | undefined): void {
|
|
188
|
+
if (!isCacheDiagnosticEnabled() || !draft) return;
|
|
189
|
+
if (body && typeof body === "object") bodyDrafts.set(body, draft);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function observeOutbound(
|
|
193
|
+
inboundBody: unknown,
|
|
194
|
+
outboundBody: unknown,
|
|
195
|
+
headers: HeadersInit,
|
|
196
|
+
source: PromptCacheKeySource = "proxy-synthesized",
|
|
197
|
+
): void {
|
|
198
|
+
if (!isCacheDiagnosticEnabled()) return;
|
|
199
|
+
try {
|
|
200
|
+
if (!inboundBody || typeof inboundBody !== "object") return;
|
|
201
|
+
const draft = bodyDrafts.get(inboundBody);
|
|
202
|
+
if (!draft) return;
|
|
203
|
+
const record = outboundBody && typeof outboundBody === "object" && !Array.isArray(outboundBody)
|
|
204
|
+
? outboundBody as Record<string, unknown> : {};
|
|
205
|
+
const outbound = taggedPresence(record.prompt_cache_key, "cache-debug:prompt-cache-key", source);
|
|
206
|
+
const inboundKey = draft.promptCacheKey?.inbound;
|
|
207
|
+
if (outbound.present && inboundKey && outbound.tag === inboundKey.tag) {
|
|
208
|
+
outbound.source = inboundKey.source;
|
|
209
|
+
}
|
|
210
|
+
(draft.promptCacheKey ??= {}).outbound = outbound;
|
|
211
|
+
(draft.session ??= {}).outboundHeader = sessionPresence(headers);
|
|
212
|
+
(draft.prefix ??= {}).outbound = prefixFingerprint(outboundBody);
|
|
213
|
+
} catch {
|
|
214
|
+
/* diagnostics must never affect request handling */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function ensureDir(): void {
|
|
219
|
+
const dir = getConfigDir();
|
|
220
|
+
recordOwnedConfigPath(dir, cacheDiagnosticPath());
|
|
221
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
222
|
+
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function trimRollingFile(path: string): void {
|
|
226
|
+
const lines = readFileSync(path, "utf8").split(/\r?\n/).filter(Boolean);
|
|
227
|
+
if (lines.length <= CACHE_DEBUG_MAX_LINES) return;
|
|
228
|
+
writeFileSync(path, `${lines.slice(-CACHE_DEBUG_KEEP_LINES).join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
|
|
229
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function appendFinalCacheDiagnostic(facts: CacheDiagnosticFinalFacts): void {
|
|
233
|
+
if (!isCacheDiagnosticEnabled()) return;
|
|
234
|
+
try {
|
|
235
|
+
const draft = facts.draft ? observe(facts.requestId, facts.draft) : drafts.get(facts.requestId) ?? {};
|
|
236
|
+
drafts.delete(facts.requestId);
|
|
237
|
+
const inboundKey = draft.promptCacheKey?.inbound;
|
|
238
|
+
const outboundKey = draft.promptCacheKey?.outbound;
|
|
239
|
+
const inboundPrefix = draft.prefix?.inbound ?? prefixFingerprint(undefined);
|
|
240
|
+
const outboundPrefix = draft.prefix?.outbound ?? prefixFingerprint(undefined);
|
|
241
|
+
const record = {
|
|
242
|
+
version: 1 as const,
|
|
243
|
+
ts: Date.now(),
|
|
244
|
+
requestId: facts.requestId,
|
|
245
|
+
...(facts.logicalRequestId ? { logicalRequestId: facts.logicalRequestId } : {}),
|
|
246
|
+
protocol: facts.protocol,
|
|
247
|
+
provider: facts.provider,
|
|
248
|
+
model: facts.model,
|
|
249
|
+
promptCacheKey: {
|
|
250
|
+
inbound: inboundKey ?? { present: false },
|
|
251
|
+
outbound: outboundKey ?? { present: false },
|
|
252
|
+
...(inboundKey?.present && outboundKey?.present ? { equal: inboundKey.tag === outboundKey.tag } : {}),
|
|
253
|
+
},
|
|
254
|
+
session: {
|
|
255
|
+
inboundHeader: draft.session?.inboundHeader ?? { present: false },
|
|
256
|
+
outboundHeader: draft.session?.outboundHeader ?? { present: false },
|
|
257
|
+
},
|
|
258
|
+
prefix: {
|
|
259
|
+
inbound: inboundPrefix,
|
|
260
|
+
outbound: outboundPrefix,
|
|
261
|
+
...(firstDivergence(inboundPrefix, outboundPrefix)
|
|
262
|
+
? { firstDivergentBlock: firstDivergence(inboundPrefix, outboundPrefix) }
|
|
263
|
+
: {}),
|
|
264
|
+
},
|
|
265
|
+
route: {
|
|
266
|
+
provider: facts.provider,
|
|
267
|
+
model: facts.model,
|
|
268
|
+
...(facts.accountLogLabel
|
|
269
|
+
? { accountTag: tag("cache-debug:account-log-label", facts.accountLogLabel) }
|
|
270
|
+
: {}),
|
|
271
|
+
...(facts.affinityMove ? { affinityMove: facts.affinityMove } : {}),
|
|
272
|
+
...(facts.affinityReason ? { affinityReason: facts.affinityReason } : {}),
|
|
273
|
+
},
|
|
274
|
+
cache: {
|
|
275
|
+
rawUpstream: facts.rawCacheCounterValue !== undefined
|
|
276
|
+
? { present: true, value: facts.rawCacheCounterValue }
|
|
277
|
+
: { present: false },
|
|
278
|
+
normalized: {
|
|
279
|
+
present: facts.normalizedCacheValue !== undefined,
|
|
280
|
+
...(facts.normalizedCacheValue !== undefined ? { value: facts.normalizedCacheValue } : {}),
|
|
281
|
+
provenance: facts.cacheProvenance,
|
|
282
|
+
},
|
|
283
|
+
},
|
|
284
|
+
};
|
|
285
|
+
ensureDir();
|
|
286
|
+
const path = cacheDiagnosticPath();
|
|
287
|
+
appendFileSync(path, `${JSON.stringify(record)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
288
|
+
try { chmodSync(path, 0o600); } catch { /* best-effort */ }
|
|
289
|
+
if (existsSync(path)) trimRollingFile(path);
|
|
290
|
+
} catch {
|
|
291
|
+
/* diagnostics must never affect request handling */
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const CACHE_DIAGNOSTIC_HOOK = Symbol.for("opencodex.cache-diagnostic.v1");
|
|
296
|
+
interface CacheDiagnosticHooks {
|
|
297
|
+
observeInbound(body: unknown, headers: Headers, source: PromptCacheKeySource): CacheDiagnosticDraft;
|
|
298
|
+
rebind(body: unknown, draft: CacheDiagnosticDraft | undefined): void;
|
|
299
|
+
finalize(facts: CacheDiagnosticFinalFacts): void;
|
|
300
|
+
}
|
|
301
|
+
(globalThis as Record<symbol, CacheDiagnosticHooks | undefined>)[CACHE_DIAGNOSTIC_HOOK] = {
|
|
302
|
+
observeInbound,
|
|
303
|
+
rebind: rebindCacheDiagnosticBodyAlias,
|
|
304
|
+
finalize: appendFinalCacheDiagnostic,
|
|
305
|
+
};
|