mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
cold-audit catch from 2026-05-24 (round 3)
Round-3 cold-audit Finding 2 (P2 observability gap). ADR 0004 §
Observability headers requires per-hop structured log events to carry
7 fields: timestamp, chain id, hop index, failed provider, trigger type,
IR request hash, and downstream provider that was tried next. Pre-D28
events only carried timestamp/hop/provider/model/error/code — operators
could not:
- correlate hops across one logical request (no chain_id)
- pivot per-prompt via fingerprint (no ir_request_hash)
- tell from one log line which bucket classified the error (no
trigger_type)
- see lookahead routing (no next_provider)
Changes (3 files, +431 / -3):
1. lib/cache/keys.mjs (+35):
- New export `computeIRRequestHash(ir)` returning a 16-char SHA-256
prefix over the IR fields that define request semantics
(messages-normalized, tools, temperature, max_tokens, top_p, stop,
tool_choice, response_format). Excludes provider/model/cache_control
so the hash is provider-agnostic — identical prompts across a
fallback chain produce identical fingerprints, enabling log
correlation. 16-char prefix gives 2^64 collision resistance —
astronomically safe for the use case while keeping log lines compact.
2. lib/fallback/engine.mjs (+86 / -3):
- New helper `classifyTrigger(error)` returning one of:
'hard' / 'auth_missing' / 'client_error' / 'non_trigger' / null.
ProviderError with HARD_TRIGGER_CODES → 'hard'; AUTH_MISSING → its
own bucket; HTTP 400/401/403/404/422 → 'client_error' (per ADR 0004
§ "No fallback for client-side errors"); HTTP 5xx or non-client 4xx
(e.g. 429/529) → 'hard'; other → 'non_trigger'. 'soft' is reserved
in JSDoc for forward-compat per D22 Amendment 2's deferral of soft
triggers to v1.x.
- `executeWithFallback` now generates `chainId` (crypto.randomBytes(8)
.toString('hex')) and `irRequestHash` (computeIRRequestHash(ir))
ONCE at entry — both stay constant across all hops in the chain.
- All 8 logEvent call sites augmented with the 4 new fields:
fallback_soft_trigger, fallback_hop_success, fallback_hop_error,
fallback_client_error_no_fallback, fallback_auth_missing_no_fallback,
fallback_hard_trigger, fallback_non_trigger_error,
fallback_chain_exhausted.
- `next_provider` is `chain[i + 1]?.provider ?? null` on advancement
events; null on terminal events (success / client_error /
auth_missing / chain_exhausted).
- chain_id NOT reused from generateRequestId() since that produces
OpenAI-response-scoped `chatcmpl-<base64url>` IDs; chain_id is
internal log correlation and uses a clean 16-char hex token.
3. test-features.mjs (+313): 24 new tests covering:
- computeIRRequestHash determinism + 16-char hex shape + 8 per-field
sensitivity tests (one per IR input field)
- Provider-agnosticism (same hash across different provider/model)
- chain_id uniqueness across calls + consistency across hops
- trigger_type for each branch (hard / auth_missing / client_error /
non_trigger / null)
- next_provider correctness (chain[i+1] on advance; null at
exhaustion)
Tests: 376 → 400 (+24). All pass on Node 20.
Pure observation-only change: no fallback decision logic touched.
`evaluateHardTriggers`, `evaluateSoftTriggers`, `HARD_TRIGGER_CODES`,
`CLIENT_ERROR_STATUSES` are only READ by `classifyTrigger`; the existing
decision branches are textually unchanged — only the trailing logEvent
object literals are expanded.
Authority:
- ADR 0004 § Observability headers (the 7-field requirement)
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0004-fallback-engine.md
- ADR 0004 § "No fallback for client-side errors" — 401/403/404/422
bucket (used by classifyTrigger's CLIENT_ERROR_STATUSES branch)
- ADR 0005 Amendment 2 (D15) — cache key composition fields, mirrored
in computeIRRequestHash minus provider/model/cache_control
- D22 Amendment 2 (ADR 0004) — soft triggers deferred to v1.x;
'soft' reserved in JSDoc but unreturnable
- CC 开发铁律 v1.6 § 10.x — Round-3 Cold Audit caught this
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE. Traced all 8 classifyTrigger branches against
ADR 0004 wording; verified 401/403/404/422 correctly classified as
'client_error' (never 'hard') per the ordering of CLIENT_ERROR_STATUSES
check before the generic status>=400 fallthrough; confirmed observation-
only via diff inspection (only logEvent object literals expanded, no
control-flow changes); independently ran git-stash to verify pre-D28
baseline 376 + 24 = 400.
3 non-blocking suggestions noted (JSDoc 'soft' union vs unreachable
return; future cross-correlation of chain_id with chatcmpl response IDs;
log-line redundancy between fallback_hop_error + fallback_hard_trigger
for same hop) — all cosmetic, not folded.
Note on D27 CI: D27 first CI run failed 1/376 on a Suite 17 port-
collision flake (overlapping random port ranges across Suite 17 tests +
Linux TIME_WAIT timing). Re-run was green. The flake is pre-existing
from D18-era test design; tracked as D29 follow-up to switch Suite 17
to OS-assigned port 0. D28 doesn't touch Suite 17 and is unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
256 lines
9.8 KiB
JavaScript
256 lines
9.8 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.
|
|
const keyObj = {
|
|
provider,
|
|
model,
|
|
messages: normalized,
|
|
tools: ir.tools ?? null,
|
|
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: ir.stop ?? null,
|
|
tool_choice: ir.tool_choice ?? null,
|
|
};
|
|
|
|
return createHash('sha256').update(JSON.stringify(keyObj)).digest('hex');
|
|
}
|