mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
de9f3ca7c90a9a6dff1d6eb1dce56751e5b5a479
3
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
4a238c9cc4 |
feat(fallback)+chore(cache): D28 — per-hop log observability fields (round-3 F2)
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>
|
||
|
|
8ae77c3ae3 |
fix(cache)+docs(adr-0005): D15 — expand cache key composition to include max_tokens/top_p/stop/tool_choice
cold-audit catch from 2026-05-23
Cold-audit Finding 7 (P2 cache correctness). ADR 0005 § Cache key
composition (v1.0) listed 7 fields. ADR 0003 § Optional fields added
`max_tokens`, `top_p`, `stop`, `tool_choice` as IR-carried fields
that affect model output. Pre-D15 cache key omitted those 4 —
identical IRs differing only in those fields collided on the same
cache key but produced legitimately different outputs. Concrete
hazard: a request with `max_tokens: 100` could receive a cached
response generated by `max_tokens: 4000` — wrong content (truncated
or unexpectedly extended).
Coordinated change across two layers, single commit per the ADR-with-
code pattern established by D11:
1. docs/adr/0005-cache-cross-provider.md — Amendment 2 (top of doc,
matching D11 Amendment 1 placement convention)
- Documents the 4 missing fields + concrete failure mode
- Expands the v1.0 cache key composition spec
- Documents the `?? null` collapsing semantics (explicit-null
equals absent — consistent with existing temperature/response_format)
- Adds forward-looking note: future IR field additions must be
evaluated for cache-key inclusion at addition time; default is
include unless explicit rationale documents safe omission
2. lib/cache/keys.mjs — append the 4 fields to `keyObj` after the
existing 7 (preserves field ordering; pre-D15 cache entries are
forced misses on first request — schema forward-compatible)
- `max_tokens: ir.max_tokens ?? null`
- `top_p: ir.top_p ?? null`
- `stop: ir.stop ?? null`
- `tool_choice: ir.tool_choice ?? null`
- Updated file-level docblock + function-level JSDoc + @param ir
enumeration to reflect new schema (the @param fix also closes a
pre-existing partial-list issue that omitted cache_control)
3. test-features.mjs — 5 new tests in Suite 9:
- max_tokens differs → different key
- top_p differs → different key
- stop differs → different key
- tool_choice differs → different key (covers string-form
`'auto'` vs `'none'`; object-form coverage left for future
defense-in-depth — slot existence is verified by the string case)
- Both-absent stability check (`?? null` collapsing produces
identical keys for omitted-vs-omitted)
Tests: 292 → 297 (+5). No hash constants pinned in existing tests
(grep verified) so no pre-D15 tests required updating; assertions
were already shape-based (`assert.equal` / `assert.notEqual` on
hash strings, never specific hex values).
Authority:
- ADR 0003 § Optional fields — source of the 4 IR field definitions
https://github.com/dtzp555-max/olp/blob/main/docs/adr/0003-intermediate-representation.md
- ADR 0005's stated invariant: "different output → different cache
entry" — the addition restores compliance with this invariant
- OpenAI /v1/chat/completions spec — confirms the 4 fields affect
output:
https://platform.openai.com/docs/api-reference/chat/create
- ALIGNMENT.md Rule 2(c) spirit — ADR amendment + code change land
in same merge (D11 precedent established this pattern for
Provider-contract changes; D15 applies same pattern for cache-key
schema changes)
- CC 开发铁律 v1.6 § 10.x — Cold Audit caught this; diff-review pass
that approved original ADR 0005 did not cross-reference all ADR
0003 optional fields
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Folded the one minor (`@param ir`
JSDoc stale partial list) before commit — same JSDoc precision logic
that drove D11's B1 fold-in. Two remaining non-blocking suggestions
(tool_choice object-form test coverage; D11-vs-D15 amendment structure
template) tracked as defense-in-depth opportunities, not required for
spec compliance.
Reviewer's highest-value verification: field ordering preserved.
keyObj at lib/cache/keys.mjs:203-217 reads exactly:
`{provider, model, messages, tools, temperature, response_format,
cache_control, max_tokens, top_p, stop, tool_choice}` — existing 7
in unchanged positions, 4 new appended at end. JSON.stringify on
Node 18+ preserves insertion order; SHA-256 hash deterministic
across runs.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
8dd02e77ac |
feat(phase-1): land cache layer (D1+D4) + Anthropic E2E gate (D5)
Phase 1 Day 3. ADR 0005 cache layer ships: content-hash key over (provider,
model, IR), D1 per-key isolation, D4 singleflight. server.mjs wires cache
into the dispatch path with X-OLP-Cache: hit|miss|bypass header. Suite 10
adds a gated real Anthropic spawn E2E (OLP_RUN_E2E=1) — orchestrator ran
it once on Mac mini: 7.2s wall-clock, real claude-haiku-4-5 spawn via
keychain OAuth, returned "OK", asserted X-OLP-Provider-Used: anthropic +
X-OLP-Cache: miss on first request. The plugin chain (IR to claude -p to
IR to OpenAI) now works end-to-end on a real binary.
Files:
NEW: lib/cache/keys.mjs (199 lines) — computeCacheKey + cache_control
extraction. sha256(stable-JSON(provider, model, normalized messages,
tools, temperature, response_format, cache_control_markers)).
NEW: lib/cache/store.mjs (348 lines, includes peek() fold-in) — in-memory
CacheStore with D1 per-key isolation (nested Maps), D4 singleflight
via getOrCompute(keyId, cacheKey, computeFn), TTL expiry,
injectable _nowFn for deterministic tests, stats reporting,
scoped clear(keyId?).
MOD: server.mjs (+136/-26 lines) — cache lookup before spawn, bypass on
cache_control markers, getOrCompute for D4 singleflight, header
annotation. authContext changed from {} to null so providers
correctly fall back to readAuthArtifact.
MOD: test-features.mjs (+614 lines, 30 new Suite 9 tests + 1 gated
Suite 10 E2E).
Test count: 98 → 128 (+30) at default. With OLP_RUN_E2E=1: 129/129
(verified by orchestrator on Mac mini, Node 25.8.0).
Authority citations:
Cache key composition: ADR 0005 § Cache key composition (v1.0). All 7
spec fields present (provider, model, normalized messages, tools,
temperature, response_format, cache_control markers).
Per-key isolation D1: ADR 0005 § D1 + OCP keys.mjs precedent (nested
Map keyId → cacheKey → entry).
Singleflight D4: ADR 0005 § D4 + OCP server.mjs inflight Promise
precedent. getOrCompute synchronously registers the inflight promise
BEFORE any await, so concurrent callers cannot observe an empty
inflight slot — JavaScript event-loop guarantee on this is the
correctness anchor.
cache_control bypass D2 basic structure: ADR 0005 § D2. Full marker-
strip-for-non-Anthropic-provider behaviour deferred (only anthropic
plugin exists at D4, so the marker-strip branch is unreachable).
Chunked stream replay D3 basic structure: ADR 0005 § D3. Full timing-
accurate replay deferred per orchestrator spec; D5 stores collected
chunks and replays sequentially without timing fidelity.
Architectural decisions:
1. In-memory cache at D5. ADR 0005 § Cache directory structure shows
~/.olp/cache/<keyId>/... as the eventual layout; D5 ships the
structural equivalent (nested Maps) in memory. File backing lands
in a later Phase. The Map structure is identical to the eventual
filesystem layout; migration is a serializer/deserializer pair.
2. keyId = '__anonymous__' at D5. Per-OLP-API-key namespacing infra
lands in Phase 2 multi-key. The constant is hardcoded in
server.mjs with a comment explaining the Phase 2 transition.
3. authContext changed from {} to null. {} ?? readAuthArtifact()
never falls back (empty object is truthy under ??); null
correctly triggers the fallback. Confirmed by D5 E2E test which
used the keychain OAuth path end-to-end.
4. cache_control side-channel via raw body. The IR translator
(lib/ir/openai-to-ir.mjs) strips cache_control because it is not
an IR v1.0 field. server.mjs bypass check uses both hasCacheControl
(ir) and extractCacheControlMarkers(body?.messages) to compensate.
The proper fix is an ADR 0003 amendment to preserve cache_control
in IR; tracked as a Phase 2 backlog item. Suite 9 Test 30 verifies
the dual-check works end-to-end over HTTP.
5. collectAllChunks throws ProviderError on type:error chunks. This
prevents cache_store.set() from being called on error-terminated
responses per ADR 0005 § Cache write conditions item 1. Anthropic
plugin currently never emits type:error chunks (throws instead),
so this is defensive code for future provider plugins.
Reviewer chain (Iron Rule 10):
Implementer: sonnet (general-purpose).
Fresh-context reviewer: opus (ecc:code-reviewer). Verdict
APPROVE_WITH_MINOR.
Reviewer ran npm test (128/128 pass with Suite 10 skipped), opened
ADR 0005 end-to-end, opened OCP keys.mjs + server.mjs to verify
singleflight precedent, verified the singleflight invariant by code
inspection (5-concurrent Test 23 plus event-loop guarantee proof).
Reviewer non-blocking findings folded in this commit:
1. peek() added to CacheStore — stats-neutral existence check.
server.mjs preCheck now uses peek() instead of has(), fixing the
hit/miss counter double-count bug. Documentation on has() updated
to point callers at peek() for stats-sensitive paths.
2. collectAllChunks now throws ProviderError on type:error chunks
instead of returning an error-terminated array, preventing cache
pollution per ADR 0005 § Cache write conditions item 1.
3. Cache key composition comment clarifies that cache_control slot is
forward-compat infrastructure for the future ADR 0003 amendment;
v1.0 IR strips cache_control so the slot is always null at the
key-composition site, with the D2 bypass side-channeling through
the raw body in server.mjs.
Reviewer findings deferred:
- IR amendment to preserve cache_control as first-class IR field
(would let server.mjs drop the dual-check). Tracked as Phase 2
backlog: "amend ADR 0003 to preserve cache_control markers in IR".
- LRU-by-linked-list eviction at maxEntriesPerKey scale. Current
O(n log n) sort-on-evict is fine at personal/family scale.
Tracked for revisit if cap is raised significantly.
Verification:
node --check on all touched files: clean.
npm test on Node 25.8.0: 128/128 pass in 210ms (default mode).
OLP_RUN_E2E=1 npm test on Mac mini: 129/129 pass in 7.4s (real
claude-haiku-4-5 spawn, "OK" response, all OLP headers correct).
hygiene grep: no personal names, no /Users literal paths, no real
OAuth tokens (test fixtures use "<fake-token>" placeholders).
Co-Authored-By: Claude Opus 4.7 (noreply@anthropic.com)
|