mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-21 21:15:10 +00:00
fix+docs: D33 — round-5 cleanup batch (F1/F3/F5/F8/F9/F10/F11/F12)
cold-audit catch from 2026-05-24 (round 5)
Round-5 cold-audit cleanup batch. 8 items + 1 release-discipline
reconciliation. Largest batch by line count (582+/39-) but every item
is small-and-focused. 3 P2 items (F1/F3/F5 of which F3 + F1 are real
correctness/observability fixes; F5 backfills /health to spec).
Changes (10 files, +583/-39):
**P2 fixes**
1. **F1 — ALIGNMENT.md mistral authority pin self-contradicted plugin**
(ALIGNMENT.md): row cited `vibe --prompt --output json` but mistral.mjs
uses `--output streaming` (the plugin header at lines 360-369 even
justifies WHY: `--output json` emits single blob, breaks NDJSON
line-buffered parser). Constitution self-contradicting itself —
missed across 4 prior rounds. Pin updated to `--output streaming`
with DOCS-1 reference.
2. **F3 — Deterministic function_call synth ID**
(lib/ir/openai-to-ir.mjs): deprecated `function_call` translation
produced `id: \`fc-${Date.now()}\`` → ID flows into normalized
tool_calls → cache key SHA-256. Two identical requests separated
by ≥1ms → different cache keys → cache always misses for
`function_call` request shape. Violates ADR 0005 invariant
"same inputs → same key, no random, no timestamp."
Fixed: id is now `fc-<16-hex>` from SHA-256 of `${name}\0${arguments}`.
NUL separator prevents the (name='ab',args='c') vs (name='a',args='bc')
collision. 2^64 collision resistance is more than sufficient for
tool_call ID disambiguation (per-request semantic key, not crypto
primitive).
**P3 fixes**
3. **F5 — /health invokes per-plugin healthCheck()** (server.mjs +
docs/openai-spec-pin.md): ADR 0002 says "healthCheck — startup AND
/health endpoint use this." Pre-D33 /health returned only
{enabled, available} counts. Now async, iterates loadedProviders,
awaits each plugin's healthCheck() in try/catch. Returns
`providers: {enabled, available, status: {<name>: {ok, latencyMs?, error?}}}`.
4. **F8 — X-OLP-Cache reports fallback-hop cache hits** (server.mjs):
pre-D33 cacheStatus computed from `preCheckHit && fallbackHops === 0`
— only counted primary-hop cache hits. When fallback fires + the
fallback hop's getOrCompute returns from cache, header reported
`miss` despite no spawn happening.
Fixed: peek BEFORE getOrCompute inside executeHopFn, set
`lastHopWasCached` closure variable on every hop (last-write-wins
= serving hop's state). cacheStatus combines
`lastHopWasCached || (preCheckHit && fallbackHops === 0)`.
F8 chose option (b) peek-then-getOrCompute over option (a)
getOrCompute API change because option (a) would break ~15 test
callsites for marginal benefit. Accepted race window same as
existing preCheckHit pattern.
5. **F9 — validateProvider hints error message updated** (lib/providers/
base.mjs): pre-D33 message listed cacheable as missing and
maxSpawnTimeMs as required. Now: `'hints must be an object with
{ requiresTTY, concurrentSpawnSafe, maxConcurrent } + optional
{ maxSpawnTimeMs, cacheable }'`.
6. **F10 — Dead cache-write branch removed** (server.mjs): the
`if (hasStopChunk)` check in the streaming stop-less exhaustion
branch was unreachable (the stop-chunk completion path returns
earlier inside the for-await loop). Removed the dead code + added
a comment documenting the invariant.
**Governance/policy**
7. **F11 — Phase rolling mode policy formalized** (CLAUDE.md +
CHANGELOG.md): 22+ D-day commits accumulated under "Unreleased"
without per-D version bumps — Iron Rule 5 (release-kit bump-before-
push) appeared to be silently violated. Reality: per-D bumps would
produce 30+ noise tags during Phase 1. F11 formalizes the policy:
intra-Phase D-day commits accumulate under Unreleased; bump+tag
fires explicitly at Phase close (maintainer-triggered, not
automated). CLAUDE.md release_kit overlay gains `phase_rolling_mode`
block documenting the exception with self-pointer ("if Rule 5
appears silently violated, check this section first"). CHANGELOG
"Unreleased" gets a notice at top.
**No version bump, no git tag in D33** — policy formalization only.
8. **F12 — /v1/models created is stable per-model timestamp**
(models-registry.json + lib/providers/index.mjs + server.mjs +
docs/openai-spec-pin.md): pre-D33 used Math.floor(Date.now()/1000)
per request — violates OpenAI spec which treats `created` as
per-model attribute. Clients caching models by created would see
spurious updates on every poll.
Fixed: models-registry.json gains `bootstrapCreated: 1778630400`
top-level constant + per-model `created` fields where known
(anthropic claude-{opus,sonnet,haiku} with estimated release dates;
devstral models from "25-12" suffix; codex models pinned to
bootstrap pending verified release dates). handleModels uses
`getModelCreated(modelId)` helper from lib/providers/index.mjs.
Aliases share canonical's timestamp.
**Tests** (test-features.mjs): 401 → 414 (+13):
- F3 ×3 (same input → same id → same cache key; different name → different)
- F5 ×4 (empty/single/multi/throwing-plugin /health shapes)
- F8 ×1 (2-hop primary-fail + secondary-cache-hit → X-OLP-Cache: hit)
- F12 ×5 (stability/fallback/alias-equals-canonical)
Pre-commit fold-in (per evidence-first checkpoint #4):
- **D33 reviewer flagged F3 empty-args asymmetry** (Concern #1): hash
input used `?? ''` (empty stays) but emitted IR field used
`|| '{}'` (empty becomes '{}'). Consequence: `arguments: ''` and
`arguments: '{}'` emit identical IR but compute different ids →
different cache keys for semantically-identical requests. The exact
cache-stability bug F3 was supposed to fix.
Folded in: canonicalize empty-args to '{}' BEFORE hashing. Hash
input now matches IR emission exactly. Same line change resolves
the asymmetry.
Authority:
- ALIGNMENT.md self-amendment (F1 pin correction)
- ADR 0005 invariant "same inputs → same key, no random, no timestamp"
(F3 restoration)
- ADR 0002 § Provider contract "/health uses healthCheck" (F5)
- ADR 0004 § Observability headers (F8 X-OLP-Cache correctness)
- ADR 0005 § Cache write conditions item 1 (F10 truncation-not-cached
invariant explicit)
- Iron Rule 5 (F11 release-kit reconciliation)
- OpenAI /v1/models spec — `created` per-model stable (F12)
- CC 开发铁律 v1.6 § 10.x — Round-5 Cold Audit caught all 8
Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Verified:
- F1 plugin cross-reference (mistral.mjs:360-369) accurately documents
the rationale
- F3 collision resistance + NUL separator + restored cache invariant
- F5 all 4 cases (empty/single/multi/throwing) work
- F8 closure semantics across multi-hop chains (verified hop-fail +
fallback-hit case)
- F10 dead code removal preserves the stop-chunk completion path
- F11 phase_rolling_mode policy honest about what happened and what
the going-forward rule is
- F12 stability across consecutive /v1/models calls; alias-canonical
parity
- 414/414 tests pass
3 remaining non-blocking suggestions (F3-vs-modern-tool_calls path
canonicalization symmetry; F12 codex models explicit-vs-fallback
writeup mismatch; F8 servingHopWasCached naming) tracked as future
polish; not folded.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
+55
-21
@@ -29,7 +29,7 @@ import {
|
||||
generateRequestId,
|
||||
SSE_DONE,
|
||||
} from './lib/ir/ir-to-openai.mjs';
|
||||
import { loadProviders, listAllProviderNames, getAliasMap } from './lib/providers/index.mjs';
|
||||
import { loadProviders, listAllProviderNames, getAliasMap, getModelCreated } from './lib/providers/index.mjs';
|
||||
import { ProviderError } from './lib/providers/base.mjs';
|
||||
import { computeCacheKey, hasCacheControl, extractCacheControlMarkers } from './lib/cache/keys.mjs';
|
||||
import { CacheStore } from './lib/cache/store.mjs';
|
||||
@@ -267,15 +267,25 @@ function olpErrorHeaders({ startMs, model }) {
|
||||
|
||||
/**
|
||||
* GET /health
|
||||
* Returns server health including count of loaded providers.
|
||||
* Returns server health including count of loaded providers and per-provider
|
||||
* healthCheck() snapshots (ADR 0002 § Provider contract: healthCheck is used
|
||||
* by startup and /health endpoint per ADR 0002 § Provider contract description).
|
||||
*/
|
||||
function handleHealth(req, res) {
|
||||
async function handleHealth(req, res) {
|
||||
const enabled = loadedProviders.size;
|
||||
const available = listAllProviderNames().length;
|
||||
const providerStatuses = {};
|
||||
for (const [name, provider] of loadedProviders) {
|
||||
try {
|
||||
providerStatuses[name] = await provider.healthCheck();
|
||||
} catch (e) {
|
||||
providerStatuses[name] = { ok: false, error: e.message };
|
||||
}
|
||||
}
|
||||
sendJSON(res, 200, {
|
||||
ok: true,
|
||||
version: VERSION,
|
||||
providers: { enabled, available },
|
||||
providers: { enabled, available, status: providerStatuses },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -295,7 +305,6 @@ function handleHealth(req, res) {
|
||||
* Empty case: if no providers are enabled, data: [] is returned naturally.
|
||||
*/
|
||||
function handleModels(req, res) {
|
||||
const createdTs = Math.floor(Date.now() / 1000);
|
||||
const data = [];
|
||||
|
||||
// Canonical entries first
|
||||
@@ -304,19 +313,23 @@ function handleModels(req, res) {
|
||||
data.push({
|
||||
id: modelId,
|
||||
object: 'model',
|
||||
created: createdTs,
|
||||
// F12 (round-5 cold-audit): use stable per-model timestamp from
|
||||
// models-registry.json rather than Date.now() on each request.
|
||||
// OpenAI spec treats `created` as a stable per-model attribute.
|
||||
created: getModelCreated(modelId),
|
||||
owned_by: providerName,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Alias entries for loaded (enabled) providers, canonical-first ordering preserved
|
||||
for (const [alias, { providerName }] of getAliasMap()) {
|
||||
for (const [alias, { providerName, canonicalModel }] of getAliasMap()) {
|
||||
if (loadedProviders.has(providerName)) {
|
||||
data.push({
|
||||
id: alias,
|
||||
object: 'model',
|
||||
created: createdTs,
|
||||
// Alias entries use the same stable timestamp as their canonical model.
|
||||
created: getModelCreated(canonicalModel),
|
||||
owned_by: providerName,
|
||||
});
|
||||
}
|
||||
@@ -434,6 +447,14 @@ async function handleChatCompletions(req, res) {
|
||||
// If executeHopFn returns successfully, the chunks are buffered and we write
|
||||
// them to `res` only AFTER executeWithFallback returns — ensuring no writes
|
||||
// occur during chain iteration.
|
||||
//
|
||||
// F8 (round-5 cold-audit): track whether the SERVING hop's response came from
|
||||
// cache. preCheckHit only covered the PRIMARY hop's key; when a fallback hop
|
||||
// serves from its own cache, the header must report 'hit', not 'miss'.
|
||||
// lastHopWasCached is set by executeHopFn before returning so the cacheStatus
|
||||
// computation below can consume it after executeWithFallback completes.
|
||||
let lastHopWasCached = false;
|
||||
|
||||
async function executeHopFn(hopProvider, hopModel, irReq) {
|
||||
const hopCacheKey = computeCacheKey(hopProvider, hopModel, irReq);
|
||||
const hopProviderPlugin = loadedProviders.get(hopProvider);
|
||||
@@ -535,7 +556,16 @@ async function handleChatCompletions(req, res) {
|
||||
// to satisfy ADR 0005 § "Cache write conditions" item 1 (no truncation).
|
||||
// D4 singleflight is preserved: getOrCompute still deduplicates concurrent
|
||||
// requests during the spawn; the eviction only affects persistent caching.
|
||||
//
|
||||
// F8 (round-5 cold-audit): peek before getOrCompute so we know whether the
|
||||
// value comes from cache or is freshly computed. peek() is stats-neutral per
|
||||
// its contract (no hit/miss counter side-effect), so it does not distort the
|
||||
// stats that getOrCompute will update on the canonical miss path.
|
||||
// This is option (b) of the F8 spec: check cache BEFORE calling getOrCompute.
|
||||
const hopWasCached = await cacheStore.peek(keyId, hopCacheKey);
|
||||
const result = await cacheStore.getOrCompute(keyId, hopCacheKey, collectAllChunks);
|
||||
// Record for use in cacheStatus computation after executeWithFallback returns.
|
||||
lastHopWasCached = hopWasCached;
|
||||
if (result.__truncated) {
|
||||
// Evict the truncated entry so future requests get a fresh spawn.
|
||||
// ADR 0005 § "Cache write conditions" item 1: truncated responses must not
|
||||
@@ -673,18 +703,16 @@ async function handleChatCompletions(req, res) {
|
||||
}
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
// Loop exhausted without stop chunk = truncation. The stop-chunk completion
|
||||
// path returns earlier (above, inside the for-await loop); reaching here means
|
||||
// the generator returned without emitting stop. Never cache (per ADR 0005 cache
|
||||
// write conditions item 1: truncated responses must not persist in cache).
|
||||
if (streamedChunks.length > 0 && cacheableForFirstHop) {
|
||||
const lastChunk = streamedChunks[streamedChunks.length - 1];
|
||||
const hasStopChunk = lastChunk?.type === 'stop';
|
||||
if (hasStopChunk) {
|
||||
await cacheStore.set(keyId, streamCacheKey, streamedChunks);
|
||||
} else {
|
||||
logEvent('warn', 'streaming_no_stop_chunk', {
|
||||
chunks_count: streamedChunks.length,
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
});
|
||||
}
|
||||
logEvent('warn', 'streaming_no_stop_chunk', {
|
||||
chunks_count: streamedChunks.length,
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (firstChunkEmitted) {
|
||||
@@ -805,7 +833,13 @@ async function handleChatCompletions(req, res) {
|
||||
// global flag. If the serving provider was Anthropic and markers were present,
|
||||
// the cache was bypassed; otherwise it was a hit (preCheckHit) or miss.
|
||||
const bypassCacheForServingHop = shouldBypassCacheForHop(providerUsed);
|
||||
const cacheStatus = bypassCacheForServingHop ? 'bypass' : (preCheckHit && fallbackHops === 0 ? 'hit' : 'miss');
|
||||
// F8 (round-5 cold-audit): cacheStatus accounts for fallback-hop cache hits.
|
||||
// preCheckHit covers the primary-hop case (fallbackHops===0); lastHopWasCached
|
||||
// covers any hop (including fallback hops that served from their own cache).
|
||||
// The two flags combine: if either signals a cache hit AND no bypass, report 'hit'.
|
||||
const cacheStatus = bypassCacheForServingHop ? 'bypass'
|
||||
: (lastHopWasCached || (preCheckHit && fallbackHops === 0)) ? 'hit'
|
||||
: 'miss';
|
||||
const headers = olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops });
|
||||
|
||||
if (ir.stream) {
|
||||
@@ -847,7 +881,7 @@ async function router(req, res) {
|
||||
|
||||
try {
|
||||
if (method === 'GET' && path === '/health') {
|
||||
return handleHealth(req, res);
|
||||
return await handleHealth(req, res);
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/v1/models') {
|
||||
|
||||
Reference in New Issue
Block a user