Files
taodengandClaude Opus 4.7 60570ef074 fix+docs: D34 — FINAL batch (F1+F4+F7+F8); audit cadence stops
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>
2026-05-24 19:33:48 +10:00

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