mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-19 09:45:07 +00:00
cold-audit catch from 2026-05-24 (round 6 — FINAL)
This is the closing D-day of a 24-day round-1→round-6 audit cycle.
After this commit + the 9 round-6 follow-up issue filings, no more
audit rounds. Trajectory R1=17 → R2=13 → R3=13 → R4=10 → R5=12 →
R6=14 — the method did not converge; owner chose Option A (focused
batch of most consequential items, then STOP).
Changes (6 files, +138 / -47):
**Code changes**
1. lib/cache/keys.mjs (+14/-?) — F4 P2 cache key array-field
normalization:
- New `normalizeArrayField` helper: `(Array.isArray(v) && v.length === 0) ? null : (v ?? null)`
- Applied to `tools` and `stop` in computeCacheKey
- Now `tools: []` and `tools` omitted produce IDENTICAL cache
keys (and same for `stop: []` vs omitted). ADR 0005 Amendment 2's
own claim that "[] and undefined share a cache entry" was
empirically FALSE pre-D34; round-6 reviewer verified hashes
differ. The fix makes the claim literally true at the
key-composition layer.
2. lib/providers/base.mjs (+16/-?) — F7 P2 dead error code removal:
- `QUOTA_EXHAUSTED` removed from PROVIDER_ERROR_CODES
- `RATE_LIMITED` removed from PROVIDER_ERROR_CODES
- v0.1 live codes: SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING,
SPAWN_TIMEOUT
- Comment block documents removal + cites ADR 0004 Amendment 3
3. lib/fallback/engine.mjs (+29/-?) — F7 P2 sibling:
- HARD_TRIGGER_CODES: QUOTA_EXHAUSTED + RATE_LIMITED removed
- SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING(false), SPAWN_TIMEOUT
remain
- evaluateHardTriggers HTTP-status branches KEPT (option (b)) with
forward-compat comment: "v0.1 plugins never attach statusCode;
branches reserved for v1.x when plugin gains HTTP-status parsing"
4. test-features.mjs (+93) — F4 + F7 test work:
- 4 new F4 regression tests (tools:[] vs undefined, stop:[] vs
undefined, tools:[] vs null, tools:non-empty vs undefined sanity)
- ~14 integration test code-swap edits (QUOTA_EXHAUSTED →
SPAWN_FAILED, RATE_LIMITED → SPAWN_FAILED) preserving original
hard-trigger semantic
- 2 dead unit tests for QUOTA_EXHAUSTED/RATE_LIMITED removed
(tombstone comment retained for audit trail)
**ADR amendments (docs-only, no code change)**
5. docs/adr/0004-fallback-engine.md (+12) — F7 Amendment 3:
- Documents the v0.1 hard-trigger code narrowing
- 4 live codes listed explicitly
- Captures evaluateHardTriggers HTTP-status branch retention rationale
- v1.x re-activation path: plugin gains HTTP-status parsing →
re-add codes → branches activate naturally
6. docs/adr/0005-cache-cross-provider.md (+21) — TWO amendments + 1
prior-amendment update:
- **Amendment 6 (F1 P1)**: Formal v1.x deferral of D4 streaming
singleflight. Buffered path (executeHopFn) uses cacheStore.getOrCompute
and participates in D4 fully. Streaming cache-miss path
(server.mjs:609-741) bypasses singleflight — N concurrent identical
streamers each spawn fresh. v0.1 trade-off accepted for
personal/family scale; v1.x design ADR needed for tee-streaming +
per-key inflight Map. Cross-references CLAUDE.md release_kit.
phase_rolling_mode as the deferral pattern precedent.
- **Amendment 7 (F8 P2)**: Documents the v0.1 conservative cache-key
posture: includes all IR fields including those plugins discard
(anthropic/codex/mistral drop temperature/max_tokens/top_p/stop/
tools/tool_choice at spawn). Consequence: 2 requests with different
temperature produce identical CLI output (CLI ignores) but
different cache keys → spurious miss. Trade-off justified:
spurious miss > spurious hit. v1.x forward path:
per-plugin cacheKeyFields contract extension (ADR 0002 amendment
needed). 3 implementation subtasks enumerated for the v1.x PR.
- **Amendment 2 update (F4)**: heading renamed to "Note on
null-coalescing AND array normalization"; body documents the
new normalizeArrayField helper; quotes the regression test name.
Tests: 414 → 416 (+4 F4 regression, -2 F7 dead, +0 net from F7
integration rewrites).
Pre-commit fold-in: NONE — D34 reviewer APPROVE with all 4 suggestions
non-blocking/cosmetic.
Authority:
- ADR 0005 Amendment 2 invariant restored at code level (F4)
- ADR 0005 Amendment 6 formalizes F1 streaming-singleflight deferral
per the same pattern as D22 ADR 0004 Amendment 2 soft-trigger
deferral
- ADR 0005 Amendment 7 documents F8 conservative posture as v0.1
intentional design (not an accident)
- ADR 0004 Amendment 3 narrows v0.1 trigger taxonomy (F7)
- Round-6 cold audit findings F2 / F3 / F6 / F9 / F10 / F11 / F12 /
F13 / F14 filed as GitHub issues after this commit (NOT in scope)
- CC 开发铁律 v1.6 § 10.x — final round of the audit cadence
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Critical depth checks:
- B5 over-normalization: verified `normalizeArrayField` only applies
to `tools` and `stop`; `response_format: {}` and `tool_choice: ''`
unaffected (Array.isArray guard)
- C10 test cleanup: 21 references reconciled (3 tombstone, 18
rewrites/removals); integration test rewrites preserve hard-trigger
semantics (QUOTA_EXHAUSTED → SPAWN_FAILED is also a hard trigger,
so fallback advancement behavior unchanged)
---
**End of audit cycle.** 24 D-days shipped from D10 (P1 hardening) through
D34 (final batch). 6 cold-audit rounds executed; 78+ findings raised;
~50 closed via implementation; ~28 deferred to GitHub issues / v1.x
ADR amendments. v0.1 tag remains explicit-maintainer-action per
phase_rolling_mode policy.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
266 lines
10 KiB
JavaScript
266 lines
10 KiB
JavaScript
/**
|
|
* lib/cache/keys.mjs — Cache key generation for OLP
|
|
*
|
|
* Authority: ADR 0005 § Cache key composition (v1.0) — amended 2026-05-24 by Amendment 2 (D15)
|
|
*
|
|
* Cache keys are computed over the IR (ADR 0003), not over the raw OpenAI
|
|
* request shape, so key stability is governed by OLP's own release cadence.
|
|
*
|
|
* Key composition:
|
|
* sha256(JSON.stringify({
|
|
* provider,
|
|
* model,
|
|
* messages: normalizeMessages(ir.messages),
|
|
* tools: ir.tools ?? null,
|
|
* temperature: ir.temperature ?? null,
|
|
* response_format: ir.response_format ?? null,
|
|
* cache_control: extractCacheControlMarkers(ir.messages),
|
|
* // Added by D15 (Amendment 2) — ADR 0003 § Optional fields that affect output:
|
|
* max_tokens: ir.max_tokens ?? null,
|
|
* top_p: ir.top_p ?? null,
|
|
* stop: ir.stop ?? null,
|
|
* tool_choice: ir.tool_choice ?? null,
|
|
* }))
|
|
*
|
|
* Per ADR 0005 § Per-model isolation: (provider, model) is part of the key.
|
|
* Cross-provider cache contamination is structurally impossible.
|
|
*
|
|
* Ported from OCP keys.mjs cacheHash() — generalized to include provider +
|
|
* model, uses JSON stable-key approach instead of incremental hash-update to
|
|
* support content arrays and tool definitions.
|
|
*/
|
|
|
|
import { createHash } from 'node:crypto';
|
|
|
|
// ── Message normalization ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Normalizes an IR message array for stable cache-key hashing.
|
|
* Strips unknown fields, normalizes content arrays to stable-keyed JSON.
|
|
*
|
|
* Fields kept per IR contract (ADR 0003):
|
|
* role, content, name, tool_calls, tool_call_id
|
|
*
|
|
* @param {Array<object>} messages
|
|
* @returns {Array<object>}
|
|
*/
|
|
function normalizeMessages(messages) {
|
|
if (!Array.isArray(messages)) return [];
|
|
|
|
return messages.map(msg => {
|
|
if (!msg || typeof msg !== 'object') return msg;
|
|
|
|
const norm = {};
|
|
|
|
// role is mandatory
|
|
if (msg.role !== undefined) norm.role = msg.role;
|
|
|
|
// content: string as-is; array → normalized recursive form
|
|
if (typeof msg.content === 'string') {
|
|
norm.content = msg.content;
|
|
} else if (Array.isArray(msg.content)) {
|
|
norm.content = normalizeContentArray(msg.content);
|
|
} else if (msg.content !== undefined) {
|
|
norm.content = msg.content;
|
|
}
|
|
|
|
// Optional fields carried in IR
|
|
if (msg.name !== undefined) norm.name = msg.name;
|
|
if (msg.tool_calls !== undefined) norm.tool_calls = normalizeToolCalls(msg.tool_calls);
|
|
if (msg.tool_call_id !== undefined) norm.tool_call_id = msg.tool_call_id;
|
|
|
|
return norm;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalizes a content array into stable-keyed JSON form.
|
|
* Each part is serialized with keys in sorted order so that
|
|
* equivalent parts are byte-identical regardless of property insertion order.
|
|
*
|
|
* @param {Array<object>} parts
|
|
* @returns {string} — JSON string with sorted keys per part
|
|
*/
|
|
function normalizeContentArray(parts) {
|
|
if (!Array.isArray(parts)) return parts;
|
|
return parts.map(part => {
|
|
if (!part || typeof part !== 'object') return part;
|
|
// Sort keys for stable serialization
|
|
return Object.fromEntries(
|
|
Object.keys(part).sort().map(k => [k, part[k]])
|
|
);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Normalizes tool_calls arrays, keeping only fields relevant to cache key.
|
|
*
|
|
* @param {Array<object>|undefined} toolCalls
|
|
* @returns {Array<object>|undefined}
|
|
*/
|
|
function normalizeToolCalls(toolCalls) {
|
|
if (!Array.isArray(toolCalls)) return toolCalls;
|
|
return toolCalls.map(tc => {
|
|
if (!tc || typeof tc !== 'object') return tc;
|
|
const norm = {};
|
|
if (tc.id !== undefined) norm.id = tc.id;
|
|
if (tc.type !== undefined) norm.type = tc.type;
|
|
if (tc.function !== undefined) norm.function = tc.function;
|
|
return norm;
|
|
});
|
|
}
|
|
|
|
// ── cache_control marker extraction ──────────────────────────────────────
|
|
|
|
/**
|
|
* Extracts Anthropic cache_control markers from an IR messages array.
|
|
* Used as a prerequisite for D2 (cache_control bypass).
|
|
*
|
|
* Searches messages and nested content arrays for cache_control objects.
|
|
* Per ADR 0005 § D2: Anthropic cache_control markers in the IR request
|
|
* bypass OLP's response cache when the active provider is Anthropic.
|
|
*
|
|
* @param {Array<object>} messages
|
|
* @returns {Array<object>} — array of found cache_control marker objects
|
|
*/
|
|
export function extractCacheControlMarkers(messages) {
|
|
const markers = [];
|
|
|
|
for (const msg of messages ?? []) {
|
|
if (!msg || typeof msg !== 'object') continue;
|
|
|
|
// Top-level cache_control on the message itself
|
|
if (msg.cache_control && typeof msg.cache_control === 'object') {
|
|
markers.push(msg.cache_control);
|
|
}
|
|
|
|
// Nested cache_control inside content array parts
|
|
if (Array.isArray(msg.content)) {
|
|
for (const part of msg.content) {
|
|
if (part && typeof part === 'object' && part.cache_control && typeof part.cache_control === 'object') {
|
|
markers.push(part.cache_control);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return markers;
|
|
}
|
|
|
|
/**
|
|
* Returns true if the IR request contains any Anthropic cache_control markers.
|
|
* Shortcut for the D2 bypass check in the server dispatch path.
|
|
*
|
|
* Ported from OCP keys.mjs hasCacheControl() — extended to check IR request
|
|
* shape (ir.messages) rather than raw OpenAI messages array.
|
|
*
|
|
* @param {object} ir — IR request object (ADR 0003)
|
|
* @returns {boolean}
|
|
*/
|
|
export function hasCacheControl(ir) {
|
|
if (!ir || !Array.isArray(ir.messages)) return false;
|
|
return extractCacheControlMarkers(ir.messages).length > 0;
|
|
}
|
|
|
|
// ── Per-request fingerprint (provider-agnostic) ───────────────────────────
|
|
|
|
/**
|
|
* Computes a stable per-request fingerprint over IR fields that define the
|
|
* request semantics (independent of provider/model). Used by fallback engine
|
|
* (D28 round-3 F2) to correlate per-hop log events from the same logical
|
|
* request. Returns first 16 hex characters of SHA-256 for compact log lines.
|
|
*
|
|
* Fields included (must mirror the cache key composition without (provider, model)):
|
|
* messages (normalized), tools, temperature, max_tokens, top_p, stop,
|
|
* tool_choice, response_format
|
|
*
|
|
* Note: cache_control is intentionally excluded here — it is a routing hint,
|
|
* not a semantic field that changes the request's logical identity for
|
|
* observability purposes.
|
|
*
|
|
* @param {object} ir
|
|
* @returns {string} — 16-character hex digest
|
|
*/
|
|
export function computeIRRequestHash(ir) {
|
|
// Use normalizeMessages so semantically-identical message arrays produce
|
|
// identical hashes (e.g. content array vs string normalization).
|
|
const subset = {
|
|
messages: normalizeMessages(ir.messages ?? []),
|
|
tools: ir.tools ?? null,
|
|
temperature: ir.temperature ?? null,
|
|
max_tokens: ir.max_tokens ?? null,
|
|
top_p: ir.top_p ?? null,
|
|
stop: ir.stop ?? null,
|
|
tool_choice: ir.tool_choice ?? null,
|
|
response_format: ir.response_format ?? null,
|
|
};
|
|
return createHash('sha256').update(JSON.stringify(subset)).digest('hex').slice(0, 16);
|
|
}
|
|
|
|
// ── Cache key computation ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Computes a content-addressed SHA-256 cache key over the IR request.
|
|
*
|
|
* Per ADR 0005 § Cache key composition (v1.0) — amended 2026-05-24 by Amendment 2 (D15):
|
|
* key = sha256(JSON.stringify({
|
|
* provider, model, messages, tools, temperature, response_format, cache_control,
|
|
* max_tokens, top_p, stop, tool_choice
|
|
* }))
|
|
*
|
|
* The key is deterministic: same inputs → same key. No random, no timestamp.
|
|
* Numbers are included if non-default (null/undefined excluded to keep keys narrow).
|
|
*
|
|
* Cross-provider contamination is impossible: (provider, model) is part of every key.
|
|
*
|
|
* @param {string} provider — provider name, e.g. 'anthropic'
|
|
* @param {string} model — model string, e.g. 'claude-haiku-4-5'
|
|
* @param {object} ir — IR request (ADR 0003): { messages, tools?, temperature?, response_format?, cache_control?, max_tokens?, top_p?, stop?, tool_choice? }
|
|
* @param {object} [_options] — reserved for future options (not used at D5)
|
|
* @returns {string} — 64-character hex SHA-256 digest
|
|
*/
|
|
export function computeCacheKey(provider, model, ir, _options = {}) {
|
|
const normalized = normalizeMessages(ir.messages ?? []);
|
|
const cacheControlMarkers = extractCacheControlMarkers(ir.messages ?? []);
|
|
|
|
// Only include fields that affect model output. Omit null/undefined to keep
|
|
// keys narrow (a request with no temperature and one with temperature=null
|
|
// produce the same key).
|
|
//
|
|
// Note on `cache_control`: this slot is in the key for forward-compatibility
|
|
// with a future ADR 0003 amendment that preserves cache_control markers in
|
|
// the IR. In v1.0 IR (the current schema), openAIToIR() strips cache_control
|
|
// because it's not an IR field, so this extractCacheControlMarkers(ir.messages)
|
|
// call always returns [] and the slot is always `null` here. The D2 bypass
|
|
// path in server.mjs side-channels through the raw body to compensate. Do
|
|
// NOT remove the slot — once IR carries cache_control, this key composition
|
|
// is already correct.
|
|
// F4 (ADR 0005 Amendment 2 clarification, D34): normalize array-typed fields so
|
|
// that [] (explicit empty) and undefined/null (absent) produce the same key.
|
|
// `tools: []` and `tools` omitted both mean "no tools available" — they produce
|
|
// identical model output and must collide on the same cache entry.
|
|
// `stop: []` similarly means "no stop sequences" — equivalent to absent.
|
|
// Per Amendment 2: "The normalization step at computeCacheKey collapses [] to null
|
|
// for array-typed fields (tools, stop) so the claim holds at the key-composition layer."
|
|
const normalizeArrayField = (v) =>
|
|
(Array.isArray(v) && v.length === 0) ? null : (v ?? null);
|
|
|
|
const keyObj = {
|
|
provider,
|
|
model,
|
|
messages: normalized,
|
|
tools: normalizeArrayField(ir.tools),
|
|
temperature: ir.temperature ?? null,
|
|
response_format: ir.response_format ?? null,
|
|
cache_control: cacheControlMarkers.length > 0 ? cacheControlMarkers : null,
|
|
// D15 (ADR 0005 Amendment 2): IR fields from ADR 0003 § Optional fields that affect output.
|
|
// Appended after the existing seven fields to avoid invalidating ordering-dependent keys.
|
|
max_tokens: ir.max_tokens ?? null,
|
|
top_p: ir.top_p ?? null,
|
|
stop: normalizeArrayField(ir.stop),
|
|
tool_choice: ir.tool_choice ?? null,
|
|
};
|
|
|
|
return createHash('sha256').update(JSON.stringify(keyObj)).digest('hex');
|
|
}
|