mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 13:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fd8f86942 | ||
|
|
b0c080db13 | ||
|
|
b43b07afbf | ||
|
|
04f797f917 | ||
|
|
bdfea6884b | ||
|
|
994568a8fb | ||
|
|
a718d22900 | ||
|
|
e96752a528 | ||
|
|
2600185edb |
@@ -5,7 +5,6 @@ on:
|
||||
paths:
|
||||
- 'server.mjs'
|
||||
- 'lib/**'
|
||||
- 'scripts/**'
|
||||
- 'models-registry.json'
|
||||
- '.github/workflows/alignment.yml'
|
||||
push:
|
||||
@@ -13,7 +12,6 @@ on:
|
||||
paths:
|
||||
- 'server.mjs'
|
||||
- 'lib/**'
|
||||
- 'scripts/**'
|
||||
- 'models-registry.json'
|
||||
- '.github/workflows/alignment.yml'
|
||||
|
||||
|
||||
@@ -36,6 +36,44 @@ jobs:
|
||||
fi
|
||||
echo "Tag v${TAG_VERSION} matches package.json version ${PKG_VERSION}."
|
||||
|
||||
- name: Enforce phase_rolling_mode (Unreleased must be promoted)
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ ! -f CHANGELOG.md ]; then
|
||||
echo "::warning::CHANGELOG.md not found; skipping phase_rolling_mode gate."
|
||||
exit 0
|
||||
fi
|
||||
# Per CLAUDE.md release_kit.phase_rolling_mode: a Phase-close PR must
|
||||
# promote "## Unreleased" → "## v<version>" before the tag is pushed.
|
||||
# This gate catches the failure mode where someone tags without
|
||||
# promoting — release.yml would otherwise extract a stale
|
||||
# "## v<version>" section and ignore D-day work folded into Unreleased.
|
||||
#
|
||||
# An "Unreleased" section is considered trivial (acceptable) when its
|
||||
# body is empty or contains only blank lines and parenthetical sentinels
|
||||
# like "(empty — Phase N entries land here once Phase N opens)". Any
|
||||
# other line (bullet, paragraph, sub-heading) is treated as unpromoted
|
||||
# content → the gate fires.
|
||||
UNRELEASED_BODY="$(awk '
|
||||
/^## Unreleased$/ { found=1; next }
|
||||
found && /^## / { exit }
|
||||
found { print }
|
||||
' CHANGELOG.md)"
|
||||
if [ -z "$UNRELEASED_BODY" ]; then
|
||||
echo "No ## Unreleased section found — gate passes."
|
||||
exit 0
|
||||
fi
|
||||
# Strip blank lines and parenthetical-sentinel-only lines.
|
||||
NON_TRIVIAL="$(printf '%s\n' "$UNRELEASED_BODY" \
|
||||
| sed -E '/^[[:space:]]*$/d; /^[[:space:]]*\(.*\)[[:space:]]*$/d')"
|
||||
if [ -n "$NON_TRIVIAL" ]; then
|
||||
echo "::error::CHANGELOG.md ## Unreleased section is non-trivial but tag v${{ steps.ver.outputs.version }} was pushed. Per CLAUDE.md release_kit.phase_rolling_mode, promote Unreleased → ## v<version> before tagging. Offending content:"
|
||||
printf '%s\n' "$NON_TRIVIAL" | sed 's/^/ /'
|
||||
exit 1
|
||||
fi
|
||||
echo "## Unreleased section is empty or sentinel-only — gate passes."
|
||||
|
||||
- name: Extract CHANGELOG section
|
||||
id: notes
|
||||
shell: bash
|
||||
|
||||
+12
-2
@@ -56,7 +56,7 @@ A plugin satisfying all five conditions is a **Speculative-Candidate**. It is Ru
|
||||
| Plugin file | Phase | Labelled UNPINNED assumptions |
|
||||
|---|---|---|
|
||||
| `lib/providers/codex.mjs` (D6) | Phase 2 | A3 (auth token field name), A4 (NDJSON event schema) |
|
||||
| `lib/providers/mistral.mjs` (D8) | Phase 3 | A4 (JSON output event schema), A5 (model flag), A6 (exact model IDs), A7 (streaming vs json output mode), A8 (stdin prompt passing) |
|
||||
| `lib/providers/mistral.mjs` (D8) | Phase 3 | A4 (JSON output event schema), A6 (exact model IDs), A7 (streaming vs json output mode), A8 (stdin prompt passing) |
|
||||
|
||||
The Anthropic plugin (`lib/providers/anthropic.mjs`, D4–D5) is NOT in this class — its CLI authority is pinned (`@anthropic-ai/claude-code` v2.1.89 per the Provider Authority Pins table above). It conforms to the standard Rule 4 path.
|
||||
|
||||
@@ -76,7 +76,7 @@ Each provider plugin in `lib/providers/<name>.mjs` is governed by the underlying
|
||||
|
||||
| Provider key | Provider CLI | Audit pin (TBD on Phase-1 spawn) | Risk Tier (see § Risk Tier Framework) |
|
||||
|---|---|---|---|
|
||||
| `anthropic` | `claude -p` from `@anthropic-ai/claude-code` | inherits OCP's `cli.js` 2.1.89 audit pin at fork; OLP-side pin: `@anthropic-ai/claude-code` v2.1.89 (observed at D4 — `lib/providers/anthropic.mjs` header). Re-evaluate post-2026-06-15 per One-shot Triggered Audit above. | D (pre-2026-06-15) / re-evaluate post-2026-06-15 |
|
||||
| `anthropic` | `claude -p` from `@anthropic-ai/claude-code` | inherits OCP's `cli.js` 2.1.89 audit pin at fork; OLP-side pin: `@anthropic-ai/claude-code` v2.1.89 (observed at D4 — `lib/providers/anthropic.mjs` header); transcript artifact: `docs/provider-audits/anthropic.md` (captured 2026-05-24). Re-evaluate post-2026-06-15 per One-shot Triggered Audit above. | D (pre-2026-06-15) / re-evaluate post-2026-06-15 |
|
||||
| `openai` | `codex exec --json` from OpenAI Codex CLI | Codex CLI reference page: https://developers.openai.com/codex/cli/reference (retrieved 2026-05-23 — §§ "codex exec [flags] PROMPT", "--json / --experimental-json", "--model, -m"; D6 WebFetch-verified reachable). Secondary authority: https://developers.openai.com/codex/cli/features §§ "Supported Models", "Automation". | D |
|
||||
| `mistral` | `vibe --prompt --output streaming` from Mistral Vibe CLI | Mistral Vibe terminal quickstart: https://docs.mistral.ai/mistral-vibe/terminal/quickstart (retrieved 2026-05-23 — § "--prompt flag triggers programmatic mode; --output selects format (text, json, streaming)"; D8 WebFetch-verified reachable). `--output streaming` selected (not `--output json`) because DOCS-1 § "Output Format Options" explicitly states `json` emits a single blob at the end — incompatible with the line-buffered NDJSON parser in `lib/providers/mistral.mjs`. `streaming` emits newline-delimited JSON per message, which the parser requires. See plugin header (lines 360-369). Configuration authority: https://docs.mistral.ai/mistral-vibe/terminal/configuration (§§ auth file `~/.vibe/.env`, `MISTRAL_API_KEY` env var). | D |
|
||||
| `grok` | `grok -p --output-format streaming-json` (xAI Build) | TBD at Phase 8+ enable | C |
|
||||
@@ -198,6 +198,16 @@ In addition to the recurring 14 May audit below, the following one-shot audits a
|
||||
|
||||
Any future Rule 3 deviation lands here as a numbered exception with PR link, reviewer, and rationale.
|
||||
|
||||
### Controlled deviations (entry-surface scope)
|
||||
|
||||
This subsection enumerates entry-surface behaviours that intentionally extend beyond the OpenAI `/v1/chat/completions` and `/v1/models` specifications. Each entry is a **controlled deviation**: a documented, reviewed extension that ships under Rule 2(b)'s spirit (no invention without an authority) by treating `docs/openai-spec-pin.md` as the formal contract for the deviation. The contract there is binding; this list is the index.
|
||||
|
||||
1. **`/v1/models` alias entries** — *Issue #13 (D36)*. The OpenAI `/v1/models` specification (https://platform.openai.com/docs/api-reference/models/list) enumerates one entry per canonical model ID. OLP's `/v1/models` response additionally surfaces alias entries (e.g. `claude`, `sonnet`, `opus`, `haiku` alongside the canonical `claude-opus-4-7` / `claude-sonnet-4-6` / `claude-haiku-4-5`). Alias entries use `id: <alias-string>`, `object: 'model'`, `owned_by: <same provider key as canonical>`, and `created: <same timestamp as canonical target>` per D27 F15.
|
||||
- **Rationale:** D27 F15 — onboarding friction when clients configured with `model: 'sonnet'` (a common alias used by Anthropic's own CLI and many OpenClaw-class tools) received an empty `/v1/models` response that did not surface the alias as a callable model id. Surfacing the alias makes the discovery loop usable for OpenAI-compatible clients with alias-aware UX.
|
||||
- **Formal contract:** `docs/openai-spec-pin.md § GET /v1/models` is the authoritative shape for this deviation. The deviation is bounded by: (a) `owned_by` matches the canonical target's `owned_by`; (b) `created` matches the canonical target's `created`; (c) no fields are invented beyond the four OpenAI-spec entry fields (`id`, `object`, `created`, `owned_by`); (d) alias enumeration is sourced from `models-registry.json` via `getAliasMap()` — the SPOT — not hard-coded in `server.mjs`.
|
||||
- **Compliance posture:** The deviation extends the response listing but does not invent fields or change field semantics. The risk vector is a hypothetical OpenAI-compatible client that asserts "one entry per canonical model" and trips on the extras; this risk is mitigated by the fact that aliases use the same `object: 'model'` shape, and any client iterating `data[]` simply sees more entries — none of which are malformed. No invention beyond what OpenAI's own `id` field already accepts as a free-form string.
|
||||
- **Re-evaluation trigger:** Annual audit (14 May) re-checks whether OpenAI has shipped a formal alias-listing extension to `/v1/models` (in which case OLP migrates to it), or whether the deviation should be retired (in which case clients with alias-aware UX must migrate to the canonical IDs via the alias table).
|
||||
|
||||
---
|
||||
|
||||
## Amendment Procedure
|
||||
|
||||
+101
@@ -6,6 +6,107 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
||||
|
||||
(empty — Phase 2 entries land here once Phase 2 opens)
|
||||
|
||||
## v0.1.1 — 2026-05-25
|
||||
|
||||
### Phase 1 cleanup — pre-Phase-2 batch (D35–D42, closes 16 of 17 issues)
|
||||
|
||||
**Overview.** v0.1.1 closes the post-v0.1.0 cleanup batch covering all 17 pre-Phase-2 issues raised during the 6-round cold-audit cycle on the Phase 1 deliverable. 8 D-day commits (D35–D42) shipped between 2026-05-24 and 2026-05-25. 16 issues closed; issue #16 (streaming singleflight) stays OPEN as the v1.x tracker with its design ratified in ADR 0005 Amendment 8.
|
||||
|
||||
**Test count: 416 (v0.1.0) → 468 (v0.1.1).** +52 tests across the cleanup batch.
|
||||
|
||||
### D35 — pre-Phase-2 batch #1 (issues #4 #9 #10 #11 #12)
|
||||
|
||||
- **#4 — X-OLP-Latency-Ms uniform.** Audit confirmed already-correct via D32; D35 adds the `#4-audit` regression test pinning the 5-header invariant on the 503 no-provider sendError so future drift is caught immediately.
|
||||
- **#9 — Streaming empty-then-clean-exit headers.** Zero-chunk streaming path now guards `!res.headersSent` and emits Content-Type=text/event-stream, Cache-Control=no-cache, Connection=keep-alive, X-Accel-Buffering=no, plus all 5 X-OLP-* headers via olpHeaders before writing `SSE_DONE`. Zero-chunk path correctly does NOT cache.
|
||||
- **#10 — Streaming post-first-chunk error truncation marker.** Two sibling fixes: catch-block-firstChunkEmitted=true and error-chunk-after-first-chunk both now emit synthetic `{type:'stop', finish_reason:'length'}` via `irChunkToOpenAISSE` + `SSE_DONE` + `res.end()`. Per ADR 0004 § Fallback safety: post-first-chunk truncation surfaces as `length` finish, never a hang.
|
||||
- **#11 — `validateIRRequest` irVersion strict check.** ADR 0003 IR contract pins irVersion to `'1.0'`. Validator now: `obj.irVersion !== undefined && obj.irVersion !== '1.0'` → rejection. Strict string match — `undefined` accepted (back-compat), `'1.0'` accepted, `'2.0'` rejected, numeric `1.0` rejected (`1.0 !== '1.0'`).
|
||||
- **#12 — `alignment.yml` scripts/** trigger removal.** Removed from both `push.paths` and `pull_request.paths` since the `scripts/` directory does not currently exist (planned for Phase 7).
|
||||
- **Test count:** 416 → 424 (+8).
|
||||
|
||||
### D36 — pre-Phase-2 batch #2 (issues #2 #5 #6 #13 #14 #15)
|
||||
|
||||
- **#2 — cache_control partial-noop debug log.** `server.mjs handleChatCompletions` fires `logEvent('debug', 'cache_control_partial_noop', { chain, marker_count })` at most once per request when markers present AND chain has at least one non-Anthropic hop. Per ADR 0005 § D2.
|
||||
- **#5 — ADR 0002 vibe.mjs → mistral.mjs.** § Decision filesystem layout corrected to match the shipped file naming convention (file named after provider key, not CLI binary). Amendment 5 documents the correction + makes the convention statement explicit for future contributors.
|
||||
- **#6 — mistral.mjs A5 flip + ALIGNMENT.md table update.** Header A5 (model flag) flipped from `UNPINNED-D-later-verifies` to `CONFIRMED-NOT-APPLICABLE` with DeepWiki citation; ALIGNMENT.md Speculative-Candidate table mistral row updated to remove A5.
|
||||
- **#13 — /v1/models alias governance.** ALIGNMENT.md gains "Controlled deviations (entry-surface scope)" subsection documenting the alias surface as a controlled Rule 2(b) deviation; `docs/openai-spec-pin.md` gains the alias-surfacing subsection with full 4-field contract table.
|
||||
- **#14 — cache_control slot determinism regression test.** 4 tests in test-features.mjs construct hand-built IRs with synthetic markers (bypassing openAIToIR which strips them at v0.1) and verify the cache key SHA-256 is deterministic. Per ALIGNMENT.md Rule 2 (No Invention), no `sortMarkers` helper shipped — the slot is dead-code at v0.1.
|
||||
- **#15 — Anthropic v2.1.89 transcript artifact.** New file `docs/provider-audits/anthropic.md` as a single living version-capture artifact. Records observed `claude --version` (2.1.132 at capture date 2026-05-24), pinned version (v2.1.89 from D4), drift note, sample invocation, flag-surface table for 5 OLP-consumed flags. Closes the circular ALIGNMENT.md ↔ plugin header citation by anchoring on an external artifact.
|
||||
- **Test count:** 424 → 431 (+7).
|
||||
|
||||
### D37 — release.yml phase_rolling_mode gate (issue #17)
|
||||
|
||||
- **CI gate enforcing phase_rolling_mode promotion discipline.** New "Enforce phase_rolling_mode (Unreleased must be promoted)" step in `release.yml` between the version-match check and the CHANGELOG extraction step. Awk extracts content between `## Unreleased` and the next `## ` heading; sed strips blank lines and parenthetical-sentinel-only lines. Non-trivial remaining content fails the workflow with `::error::` instructing the maintainer to promote Unreleased → `## v<version>` per CLAUDE.md release_kit.phase_rolling_mode.
|
||||
- **Dry-run validated against 4 cases:** current sentinel-only Unreleased → PASS; synthetic non-trivial Unreleased → FIRES with offending lines reported; no Unreleased section → PASS; multi-sentinel + blank lines → PASS.
|
||||
- **Gate is purely additive** — fires only on tag push to `v*.*.*`, does not affect normal push/PR CI.
|
||||
- **Test count:** 431 → 431 (no test change — CI workflow only).
|
||||
|
||||
### D38 — maxConcurrent runtime enforcement (issue #1)
|
||||
|
||||
- **Spawn lifecycle gate** — `hints.maxConcurrent` is now enforced at runtime per ADR 0002 Amendment 6: `lib/providers/index.mjs` exports a per-provider `tryAcquireSpawn` / `releaseSpawn` / `getActiveSpawnCount` semaphore; `server.mjs` gates both the buffered and streaming spawn call sites in `handleChatCompletions` with a try/finally release. Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)` which the fallback engine treats as a hard trigger (ADR 0004 Amendment 4) — the chain advances to the next hop instead of queueing. If the entire chain is saturated, the user receives a chain-exhausted error via the existing exhaustion path. Closes #1. Queue+timeout deferred (see ADR 0002 Amendment 6 § Design choice). Test count 431 → 447.
|
||||
|
||||
- **Spawn lifecycle gate** — `hints.maxConcurrent` is now enforced at runtime per ADR 0002 Amendment 6: `lib/providers/index.mjs` exports a per-provider `tryAcquireSpawn` / `releaseSpawn` / `getActiveSpawnCount` semaphore; `server.mjs` gates both the buffered and streaming spawn call sites in `handleChatCompletions` with a try/finally release. Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)` which the fallback engine treats as a hard trigger (ADR 0004 Amendment 4) — the chain advances to the next hop instead of queueing. If the entire chain is saturated, the user receives a chain-exhausted error via the existing exhaustion path. Closes #1. Queue+timeout deferred (see ADR 0002 Amendment 6 § Design choice). Test count 431 → 447.
|
||||
|
||||
### D39 — D16 follow-ups (issue #3): explicit cache delete + eviction log + SPAWN_TIMEOUT asymmetry doc
|
||||
|
||||
- **Part 1 — `CacheStore.delete(keyId, cacheKey)`** — adds an explicit eviction primitive to `lib/cache/store.mjs`. Returns `boolean` (true if entry present and removed; false otherwise) and removes empty per-keyId namespace `Map` entries from the outer store for memory hygiene (matches the D38 `_activeSpawns` pattern). `server.mjs` D16 salvage path replaces `cacheStore.set(..., ttlMs=0)` (lazy tombstone that lived in the namespace `Map` until the next `get`/`peek` purged it) with `cacheStore.delete(...)` (immediate removal). Cache semantics unchanged — truncated responses still don't persist. ADR 0005 § "Cache write conditions" item 1 authority.
|
||||
- **Part 2 — `cache_evicted_truncated` observability log** — adds an `info`-level structured log event fired immediately after the D16 eviction in `executeHopFn`. Carries `{ provider, model }` so dashboards can surface salvage frequency per (provider, model) pair. P3 polish; no semantic change.
|
||||
- **Part 3 — sticky-cache regression test** — defense-in-depth test asserting two consecutive identical buffered requests that both trigger SPAWN_FAILED-with-chunks salvage each invoke a fresh spawn (spawnCount=2 across the two requests; second request reports `X-OLP-Cache: miss`). Catches any future regression where the eviction is dropped or the gate condition flips.
|
||||
- **Part 4 — SPAWN_TIMEOUT salvage asymmetry documented (no code change)** — ADR 0004 Amendment 1 gains a new sub-section "Why SPAWN_TIMEOUT is excluded from salvage" with a 4-point rationale: (1) SPAWN_FAILED is a terminal signal, SPAWN_TIMEOUT is a deadline signal; (2) the next hop is a different provider with different speed characteristics, plausibly full-response-soon-after-T; (3) the "user paid for partial" framing applies to SPAWN_FAILED only — for SPAWN_TIMEOUT the user paid for "result within T"; (4) code inspection confirms the catch block matches only `code === 'SPAWN_FAILED'`. Includes hard-trigger-taxonomy completeness note and v1.x re-evaluation trigger (opt-in salvage-on-timeout for long deadlines).
|
||||
- **Authority:** ADR 0005 § Cache layer / CacheStore API extension (Part 1); ADR 0004 Amendment 1 (Part 4); GitHub issue #3 — closed by this commit; D16 commit `bafa6d1` non-blocking suggestions — batched here.
|
||||
- **Test count:** 447 → 452 (3 unit tests for `CacheStore.delete` + 1 log-event integration test + 1 sticky-cache regression test).
|
||||
|
||||
### D40 — `X-OLP-Fallback-Detail` header (issue #7)
|
||||
|
||||
- **New debug header on responses with a non-empty failure trail** — `lib/fallback/engine.mjs#executeWithFallback` now returns a `fallbackDetail` array of per-hop tuples on every code path. `server.mjs` emits `X-OLP-Fallback-Detail: <JSON-stringified array>` on any response where at least one hop failed before the chain resolved or exhausted (chain-exhausted, non-trigger-error, client-error, AUTH_MISSING, and success-with-prior-failure paths). Header is absent on clean primary success (no failure trail to report).
|
||||
- **Tuple schema** — `{ hop, provider, model, code, error_message, trigger_type }` per failed hop. `code` is the `ProviderError` code or `'UNKNOWN'` for non-`ProviderError` exceptions; `error_message` is truncated to 200 chars with a U+2026 ellipsis on truncation; `trigger_type` matches D28's `classifyTrigger` output (`'hard'` / `'soft'` / `'auth_missing'` / `'client_error'` / `'non_trigger'`). Field shapes reuse D28's per-hop structured log event keys so logs and the header pivot on the same surface.
|
||||
- **4KB UTF-8 byte cap** — if the JSON-stringified array exceeds 4096 bytes, tail tuples are dropped and a `{ truncated: true, omitted_hops: N }` sentinel is appended such that the total fits under the cap. Cap calculation uses `Buffer.byteLength('utf8')`, not string length.
|
||||
- **RFC 7230 hygiene** — non-ASCII code points (e.g. the em dash in the D38 `CONCURRENCY_LIMIT` synthesised error message) are escaped as `\uXXXX` so the header value is pure ASCII. Node's HTTP header validator rejects multi-byte UTF-8 in field values; without this step, em-dash-bearing error messages would crash `res.writeHead`. `JSON.parse` round-trips the escaped form correctly.
|
||||
- **Gating posture — ungated at v0.1** — the original ADR 0004 § Chain advancement step 4 specified owner-only gating. Per the maintainer decision in issue #7, v0.1 ships the header **ungated** (single-tenant family-scale per ALIGNMENT.md; no PII risk in error details). **Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands** — explicit follow-up tracked in AGENTS.md § Key files to know and ADR 0004 Amendment 5.
|
||||
- **Authority:** ADR 0004 § Decision § Chain advancement step 4 (original promise — D40 fulfils it); ADR 0004 Amendment 5 (D40 ratification); D18 (5 standard X-OLP-* headers; D40 builds on the convention); D28 (per-hop structured log fields; D40 reuses the field shapes); GitHub issue #7 — closed by this commit.
|
||||
- **Test count:** 452 → 468 (7 engine-level tuple-shape tests + 6 serialiser unit tests including the 4KB cap + non-ASCII regression + 3 HTTP integration tests).
|
||||
|
||||
### D41 — `X-OLP-Provider-Used` semantics documented (issue #8)
|
||||
|
||||
- **Doc-only clarification.** On a chain-exhausted response, `X-OLP-Provider-Used` identifies the chain's configured primary entry (`chain[0].provider`), not necessarily the first hop where `spawn()` was actually invoked. At v0.1 this is unobservable because soft triggers are deferred (ADR 0004 Amendment 2) — every hop is attempted in order, so chain-origin and first-attempted are equivalent. When soft triggers reactivate in v1.x, a soft-skipped hop 0 followed by hard-failed hops 1+N would still report `providerUsed=chain[0]` despite chain[0] never being spawned.
|
||||
- **Option B (document chain-origin) chosen over Option A (track `firstAttemptedProvider`).** Rationale: Option A would add state to `executeWithFallback` for an unreachable v0.1 code path (ALIGNMENT.md Rule 2 — No Invention). The D40 `X-OLP-Fallback-Detail` header already carries precise per-hop spawn history (including soft-skip records with `trigger_type: 'soft'`), so the disambiguation channel exists on the wire without needing `providerUsed` to handle it.
|
||||
- **Updates:** ADR 0004 Amendment 6 documents the semantics; `README.md` § Observability headers replaces "which provider's plugin served the request" with the chain-origin wording; `lib/fallback/engine.mjs` chain-exhausted return site gains an inline comment citing the amendment and the v1.x re-evaluation note.
|
||||
- **No code-behavior change. No new tests** — the relevant scenario is dead-by-config at v0.1; the v1.x soft-trigger reactivation work should add a test that exercises the soft-skip + chain-exhausted edge case and pins whichever option the v1.x maintainer chooses (the amendment names Option A as the likely v1.x preference).
|
||||
- **Authority:** ADR 0004 Amendment 6 (this commit); ADR 0004 § Decision § Chain advancement step 4; ADR 0004 Amendment 2 (soft triggers deferred — precondition); ADR 0004 Amendment 5 (per-hop attribution channel via `X-OLP-Fallback-Detail`); ALIGNMENT.md Rule 2 (No Invention rationale); GitHub issue #8 — closed by this commit.
|
||||
- **Test count:** 468 → 468 (no test change).
|
||||
|
||||
### D42 — Streaming singleflight design ADR + v1.x roadmap (issue #16)
|
||||
|
||||
- **Design-only ratification of the v1.x streaming singleflight implementation.** ADR 0005 Amendment 6 (D34) had deferred this work with a "design alone warrants a dedicated ADR" note. D42 fulfils the note as ADR 0005 Amendment 8, ratifying the `cacheStore.getOrComputeStreaming(...)` API shape, per-(keyId, cacheKey) inflight Map, tee fan-out with bounded per-client backpressure queues, late-joiner replay buffer, AbortController propagation on all-disconnect, D38 `tryAcquireSpawn` coordination (only the first caller's spawn counts against the semaphore), cache TTL race handling, the new `STREAM_BACKPRESSURE` error code (NOT a hard trigger), and the new `X-OLP-Streaming-Inflight: source | attached | solo` header. Implementation acceptance criteria are enumerated in Amendment 8 §13.
|
||||
- **Multi-layer safeguards to ensure the v1.x work is not forgotten.** New file `docs/v1x-roadmap.md` is a single living landing page for every Phase-1 deferral (streaming SF, multi-key auth, soft-trigger reactivation, `/health` activeSpawns, provider-level `cacheKeyFields`, streaming-path SPAWN_FAILED salvage, D40 AUTH_MISSING tuple test). Each entry names the ratifying ADR, the load-bearing code anchor, and a concrete trigger to start. Cross-references added at: `lib/cache/store.mjs#getOrCompute` JSDoc (sibling API TODO), `server.mjs` streaming-branch entry (~line 810, the peek+spawn pattern Amendment 8 replaces), `README.md § Known limitations` (user-facing surface), and `docs/adr/0005-cache-cross-provider.md` Amendment 8 § "Cross-references and safeguards".
|
||||
- **Issue #16 status.** STAYS OPEN as the v1.x implementation tracker. The body of the issue is updated post-D42 to reference Amendment 8 and clarify scope ("design ratified; implementation pending"). DO NOT close the issue until Amendment 8 §13's test surface is green against an actual implementation.
|
||||
- **No code-behavior change. No new tests.** Amendment 8 is design-only. The implementation will go through full Iron Rule 10 (fresh-context opus reviewer + acceptance-criteria-gated test pass) when the v1.x sprint kicks off.
|
||||
- **Authority:** ADR 0005 Amendment 8 (this commit); ADR 0005 Amendment 6 (D34 — original deferral note); GitHub issue #16 (round-6 F13 — sibling TOCTOU); ADR 0002 Amendment 6 (D38 — `tryAcquireSpawn` semantics that §7 coordination builds on); ADR 0004 Amendment 5 (D40 — observability pattern §11 extends); `CLAUDE.md` release_kit_overlay phase_rolling_mode — under Unreleased; CC 开发铁律 v1.6 § 10.x (design-only amendment; fresh-context reviewer not required per the Iron Rule 10 implementation-phase scope, documented in the amendment's procedural mechanism).
|
||||
- **Test count:** 468 → 468 (no test change — design-only).
|
||||
|
||||
### Phase 1 cleanup release_kit checklist
|
||||
|
||||
- [x] All 8 D-day deliverables landed on main (D35-D42)
|
||||
- [x] CI green on every D-day commit + on this release commit's head
|
||||
- [x] Cold-audit round 7 (fresh-context opus full-pass) — PASS_WITH_MINOR, 0 P1/P2 findings
|
||||
- [x] 16 of 17 pre-Phase-2 GitHub issues closed (#1-#15 and #17); #16 stays OPEN as v1.x tracker
|
||||
- [x] Issue #16 status comment posted referencing ADR 0005 Amendment 8 design ratification
|
||||
- [x] CHANGELOG "Unreleased" promoted to "## v0.1.1 — 2026-05-25" with D35-D42 entries
|
||||
- [x] `package.json` bumped from 0.1.0 → 0.1.1
|
||||
- [x] `docs/v1x-roadmap.md` created — 7 deferred items with anchors + start triggers
|
||||
- [ ] Tag pushed (next step in this PR's lifecycle)
|
||||
- [ ] `release.yml` triggered + GitHub Release created (auto on tag push; D37 phase_rolling_mode gate will pass because Unreleased is now sentinel-only)
|
||||
|
||||
### Known limitations carried to v1.x
|
||||
|
||||
Full list with code anchors + start triggers in [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md):
|
||||
- Streaming-path singleflight (issue #16, ADR 0005 Amendment 8 design ratified)
|
||||
- Multi-key auth (`lib/keys.mjs`)
|
||||
- Soft-trigger reactivation (ADR 0004 Amendment 2)
|
||||
- `/health` activeSpawns integration (ADR 0002 Amendment 6 forward note)
|
||||
- Provider-level `cacheKeyFields` mask (ADR 0005 Amendment 7 forward note)
|
||||
- Streaming-path SPAWN_FAILED salvage (bundled with #1 in v1.x)
|
||||
- D40 AUTH_MISSING tuple test coverage (test polish)
|
||||
|
||||
## v0.1.0 — 2026-05-24
|
||||
|
||||
### Phase 1 Close — Multi-provider proxy core
|
||||
|
||||
@@ -155,7 +155,7 @@ See also the [Implementation status](#implementation-status-as-of-2026-05-24) ta
|
||||
|
||||
Every response served through OLP carries:
|
||||
|
||||
- `X-OLP-Provider-Used: <provider-key>` — which provider's plugin served the request.
|
||||
- `X-OLP-Provider-Used: <provider-key>` — which provider's plugin served the request. On a chain-exhausted response, this identifies the chain's configured primary entry (`chain[0]`), not necessarily the first hop where `spawn()` was invoked — see ADR 0004 Amendment 6 for the v0.1 chain-origin semantics and the v1.x soft-trigger reactivation note.
|
||||
- `X-OLP-Model-Used: <model-id>` — which model the served provider used.
|
||||
- `X-OLP-Fallback-Hops: <n>` — number of fallback hops (`0` if served by the primary chain entry).
|
||||
- `X-OLP-Cache: hit | miss | bypass` — cache layer outcome.
|
||||
@@ -190,6 +190,15 @@ Phase 1 is in progress. This table reflects what is currently shipped vs. what i
|
||||
| `scripts/migrate-from-ocp.mjs` | 📋 Planned (Phase 7) | OCP → OLP migration tool |
|
||||
| `setup.mjs` | 📋 Planned | Setup wizard / initial config |
|
||||
|
||||
### Known limitations
|
||||
|
||||
Behaviors that work correctly at personal/family scale but have ratified follow-ups for a v1.x sprint. Single landing page: [`docs/v1x-roadmap.md`](./docs/v1x-roadmap.md).
|
||||
|
||||
- **Streaming-path singleflight not implemented.** The cache layer's D4 singleflight (one spawn per identical concurrent request) is fully wired on the buffered path but NOT on the streaming path. N concurrent identical streaming requests at v0.1 will each spawn their own CLI process. Design ratified in [ADR 0005 Amendment 8](./docs/adr/0005-cache-cross-provider.md); implementation tracked via [issue #16](https://github.com/dtzp555-max/olp/issues/16) and [v1.x roadmap #1](./docs/v1x-roadmap.md). At family scale this is observably fine — every caller still receives the correct response; the cost is N CLI processes instead of one.
|
||||
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
||||
- **Multi-key auth not yet implemented.** All requests today share the cache namespace `__anonymous__`. The cache data model is keyed by `keyId` and ready to accept real identities when `lib/keys.mjs` lands. Tracked in [v1.x roadmap #2](./docs/v1x-roadmap.md).
|
||||
- **Provider-level `cacheKeyFields` mask not implemented.** Cache keys include every IR field including ones individual plugins drop at spawn (e.g., Anthropic plugin drops `temperature`). Spurious cache misses possible (extra spawn cost; never spurious hits). Conservative posture documented in [ADR 0005 Amendment 7](./docs/adr/0005-cache-cross-provider.md). Tracked in [v1.x roadmap #5](./docs/v1x-roadmap.md).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -7,6 +7,41 @@
|
||||
|
||||
## Amendments
|
||||
|
||||
> **Note on numbering.** Sequence is 1, 3, 4, 5, 6 — Amendment 2 was never written. The reserved slot was originally planned for a separate `maxConcurrent` ratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
|
||||
|
||||
### Amendment 6 — 2026-05-24: `maxConcurrent` runtime enforcement landed (D38, issue #1)
|
||||
|
||||
- **Finding:** Amendment 1 (2026-05-23) ratified `maxSpawnTimeMs` into the Provider contract but explicitly noted that `hints.maxConcurrent` remained **declarative-only at v0.1** — type-validated at startup in `lib/providers/base.mjs` (`validateProvider` requires it to be a non-negative integer) but unenforced at runtime (no semaphore / in-flight counter / spawn queue in `server.mjs`). Cold-audit catch from D11 (commit `f659e29`): the diff-review reviewer grep-verified that the original ADR draft's claim "Enforced by the spawn-concurrency guard in `server.mjs`" was false. GitHub issue #1 was filed to track the gap. D38 closes that gap.
|
||||
- **Change (D38):**
|
||||
- Add a per-provider in-flight semaphore in `lib/providers/index.mjs` exporting three primitives plus a constant:
|
||||
- `tryAcquireSpawn(providerName, maxConcurrent)` — atomic check-then-increment; returns `true` on success, `false` if at limit. Atomicity rests on the JS single-threaded invariant — the read and write are synchronous with NO `await` between them. A future async refactor MUST preserve this.
|
||||
- `releaseSpawn(providerName)` — decrement; throws if the count would go negative (defensive bug guard for missing acquire / double release).
|
||||
- `getActiveSpawnCount(providerName)` — returns current in-flight count; exported for diagnostics and tests (server.mjs uses it to populate the `activeSpawns` field on a synthesised `CONCURRENCY_LIMIT` error). `/health` integration deferred — when surfaced there it will land at `providers.status.<name>.activeSpawns`; not wired at D38.
|
||||
- `DEFAULT_MAX_CONCURRENT_SPAWNS = 4` — defense-in-depth fallback when a plugin path bypasses `validateProvider` and passes undefined/null/NaN. The value matches the v0.1 plugin defaults (anthropic / codex / mistral all declare `hints.maxConcurrent: 4`).
|
||||
- Wire the gate at both `provider.spawn(...)` call sites in `server.mjs handleChatCompletions`:
|
||||
- **Buffered path** (inside `executeHopFn → collectAllChunks`): `tryAcquireSpawn` runs before `provider.spawn(...)`. On failure, synthesise `ProviderError(CONCURRENCY_LIMIT)` with `providerName` / `maxConcurrent` / `activeSpawns` fields for diagnostics and re-throw — the fallback engine treats it as a hard trigger (see ADR 0004 Amendment 4) and advances to the next chain hop. On success, the spawn drain loop runs inside a `try { … } finally { releaseSpawn(...) }` so the slot releases on every exit path (success, error, D16 SPAWN_FAILED salvage return, unexpected throw).
|
||||
- **Streaming path** (single-hop real-SSE, `chain.length === 1` cache-miss branch): acquire happens BEFORE the streaming branch entry. If acquire fails, the branch is skipped and the request falls through to the buffered path — that path's own gate re-attempts acquire; a single-hop chain at maxConcurrent has no other hop to advance to, so the request surfaces a chain-exhausted error via `executeWithFallback`'s exhaustion path. If acquire succeeds, the existing streaming try/catch gains a `finally { releaseSpawn(streamProvider) }` so the slot releases on stop-chunk completion, generator exhaustion, abort, or any exception path.
|
||||
- Add `CONCURRENCY_LIMIT` to `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` so the synthesised error type-checks with the existing closed enum. (Note: `CONCURRENCY_LIMIT` is synthesised by the orchestration layer, NOT thrown by provider plugins themselves — the code is in the enum for type consistency with the fallback engine's `HARD_TRIGGER_CODES` lookup.)
|
||||
- Update the `maxConcurrent` description in § Decision (Provider contract hints) below — remove the "Declarative hint only at v0.1" caveat and add the implementation reference.
|
||||
- **Update to § Decision § Provider contract hints (`maxConcurrent`):** replace the v0.1 caveat with: "`maxConcurrent` — integer; maximum simultaneous spawn count OLP will allow for this provider. The value is type-validated at startup (`lib/providers/base.mjs validateProvider`) and enforced at runtime by `tryAcquireSpawn` / `releaseSpawn` in `lib/providers/index.mjs`. Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)` (per `PROVIDER_ERROR_CODES`, `lib/providers/base.mjs`), which the fallback engine treats as a hard trigger per ADR 0004 Amendment 4 — the chain advances to the next hop. If the entire chain is saturated, the user receives a chain-exhausted error via the existing `executeWithFallback` exhaustion path."
|
||||
- **Design choice — immediate-advancement vs. queue+timeout:** D38 implements immediate-advancement through the fallback chain. Rationale:
|
||||
1. The fallback chain exists precisely for this kind of overflow — saturation on the primary hop is a natural fit for the existing advancement mechanism.
|
||||
2. Queue+timeout introduces head-of-line blocking risk (a stuck/slow spawn blocks queued waiters) and adds a new timeout config surface (`hints.maxConcurrentWaitMs`?) that the contract does not currently have.
|
||||
3. Immediate-advancement gives fail-fast latency and matches the OLP multi-provider proxy philosophy (the user has spread their quota across providers explicitly so saturation should reach an alternate provider as fast as possible).
|
||||
4. Queue+timeout is **deferred to a future iteration** if real usage shows demand. Track via a follow-up issue if the design pressure surfaces.
|
||||
- **Authority:** ALIGNMENT.md Rule 1 (Cite First) — internal authority is ADR 0002 (this ADR) + ADR 0004 (which adds CONCURRENCY_LIMIT to the hard-trigger taxonomy in its Amendment 4). No provider CLI doc cited because this change is internal to the orchestration layer; no provider plugin code changes (anthropic / codex / mistral already declare `hints.maxConcurrent` correctly per validateProvider).
|
||||
- **Tests:** Suite 18 in `test-features.mjs` — 16 tests covering: `PROVIDER_ERROR_CODES` membership, `evaluateHardTriggers(CONCURRENCY_LIMIT)` returns true, semaphore unit behaviour (acquire / release / count / reset), saturation rejection, defensive coercion of non-integer maxConcurrent, double-release throws, HTTP-level concurrent-request peak-in-flight assertion (5 requests against maxConcurrent:2 → peak == 2), buffered-path counter release, streaming-path counter release, fallback advancement to secondary on saturated primary.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D38 issue #1 implementation; fresh-context opus reviewer to follow this implementation per Iron Rule 10).
|
||||
|
||||
### Amendment 5 — 2026-05-24: Correct § Decision filesystem layout — `vibe.mjs` → `mistral.mjs` (D36 #5)
|
||||
|
||||
- **Finding:** Issue #5 (D36) — § Decision filesystem layout (around line 47 of the original ADR) listed the Mistral provider plugin as `vibe.mjs` (named after the CLI binary `vibe`). The shipped file at `lib/providers/mistral.mjs` (D8) is named after the provider key, matching the established convention from the other two plugins: `anthropic.mjs` (provider key `anthropic`, CLI `claude`) and `codex.mjs` (provider key `openai`, CLI `codex`). The ADR's `vibe.mjs` entry was a drafting-time placeholder that did not get corrected when D8 landed `lib/providers/mistral.mjs`.
|
||||
- **Change:** Replace `vibe.mjs # spawn `vibe --prompt --output json`` with `mistral.mjs # spawn `vibe --prompt --output streaming`` in the filesystem layout. The `--output streaming` correction also aligns the example with the actual D8 implementation (`mistral.mjs` line 377 uses `--output streaming`, not `--output json` — see D8 review-2 finding inside the plugin header).
|
||||
- **Naming convention reaffirmed:** Provider plugin files are named after the **provider key** (`anthropic`, `openai`, `mistral`), not the CLI binary (`claude`, `codex`, `vibe`). Future provider plugins must follow this convention. The provider key is the load-bearing identifier — it appears in `models-registry.json`, cache keys, fallback chain configs, and ADR 0006 inclusion tables. The CLI binary name is an implementation detail that may change (e.g., a vendor rename) without affecting the rest of the system.
|
||||
- **Authority:** Issue #5 (D36); naming convention established by `lib/providers/anthropic.mjs` (D4) and `lib/providers/codex.mjs` (D6) which both shipped before `lib/providers/mistral.mjs` (D8).
|
||||
- **No code change:** D36 #5 is a docs-only correction. The plugin file already lives at the correct path.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D36 batch — ADR drift caught by issue-triage review of bootstrap ADRs).
|
||||
|
||||
### Amendment 4 — 2026-05-24: Ratify `contractVersion` as a required Provider contract field (D32 F5)
|
||||
|
||||
- **Finding:** Round-4 cold-audit F5 (P3 governance omission) — `lib/providers/base.mjs` `validateProvider` enforces `p.contractVersion === '1.0'` and all three shipped plugins declare it, but the Provider contract field list in § Decision (lines ~63-74) does not include `contractVersion`. It was mentioned only in § Mitigations as a forward-looking note ("The contract is versioned. v1.0 is the subset in this ADR; future additions … require ADR amendment plus a contract-version bump. Old provider plugins continue to declare `contractVersion: '1.0'`…"), not as a required field. This is the same class of documentation–implementation gap as Amendment 1 (`maxSpawnTimeMs` retroactive sync).
|
||||
@@ -60,7 +95,9 @@ lib/providers/
|
||||
index.mjs # static registry (enumeration of in-tree providers)
|
||||
anthropic.mjs # spawn `claude -p` — port of OCP server.mjs spawn logic
|
||||
codex.mjs # spawn `codex exec --json`
|
||||
vibe.mjs # spawn `vibe --prompt --output json`
|
||||
mistral.mjs # spawn `vibe --prompt --output streaming` (file named after
|
||||
# provider key per the convention established by
|
||||
# anthropic.mjs / codex.mjs — see Amendment 5)
|
||||
grok.mjs # spawn `grok -p --output-format streaming-json` (optional)
|
||||
kimi.mjs # spawn `kimi -p --output-format stream-json` (optional)
|
||||
minimax.mjs # tier-2 optional, default-disabled
|
||||
@@ -83,7 +120,7 @@ Every provider plugin exports an object conforming to:
|
||||
- `hints: { requiresTTY, concurrentSpawnSafe, maxConcurrent, maxSpawnTimeMs, cacheable }` — fingerprint, concurrency, timeout, and cache hints:
|
||||
- `requiresTTY` — boolean; whether the provider CLI requires a TTY to produce non-interactive output (e.g., some CLIs suppress JSON output unless forced with a flag or a TTY is present).
|
||||
- `concurrentSpawnSafe` — boolean; whether the provider CLI is safe to spawn concurrently under the same auth context without rate-limit or session collisions.
|
||||
- `maxConcurrent` — integer; maximum simultaneous spawn count OLP will allow for this provider. **Declarative hint only at v0.1**: the value is type-validated at startup (`lib/providers/base.mjs`) but no runtime enforcement (semaphore / in-flight counter / spawn queue) is wired in `server.mjs` yet. Tracking issue to be filed for a follow-up that lands the runtime guard.
|
||||
- `maxConcurrent` — integer; maximum simultaneous spawn count OLP will allow for this provider. The value is type-validated at startup (`lib/providers/base.mjs validateProvider`) and **enforced at runtime** by `tryAcquireSpawn` / `releaseSpawn` in `lib/providers/index.mjs` (D38 — see Amendment 6). Saturation surfaces as `ProviderError(CONCURRENCY_LIMIT)`, which the fallback engine treats as a hard trigger per ADR 0004 Amendment 4 — the chain advances to the next hop. If the entire chain is saturated, the user receives a chain-exhausted error via the existing `executeWithFallback` exhaustion path. (Pre-D38 caveat removed; tracking issue #1 closed by Amendment 6.)
|
||||
- `maxSpawnTimeMs` — optional integer, milliseconds; maximum wall-clock time OLP allows for a single provider spawn before treating it as a hard fallback trigger. Defaults to `600000` (10 minutes) if absent. Enforcement lives inside each provider plugin's spawn drain loop (`_spawnAndStream`), which uses a `setTimeout` / `proc.kill` / `reject` pattern to throw `ProviderError(SPAWN_TIMEOUT)`; the fallback engine then treats this error as a hard trigger (ADR 0004 § Trigger taxonomy — Hard triggers bullet 4). The engine itself does not run the timer loop; it only acts on the thrown error.
|
||||
- `cacheable` — optional boolean, default `true`; if explicitly set to `false`, the provider opts out of OLP's response cache entirely. `executeHopFn` skips `cacheStore.getOrCompute` and calls `collectAllChunks` directly; no cache read or write occurs for any request to this provider. Omitting the field is equivalent to `cacheable: true`. See ADR 0005 § "Cache write conditions" item 3 and Amendment 3 above. (D23)
|
||||
|
||||
|
||||
@@ -7,6 +7,55 @@
|
||||
|
||||
## Amendments
|
||||
|
||||
### Amendment 6 — 2026-05-24: `X-OLP-Provider-Used` chain-origin semantics on exhaustion (D41, issue #8)
|
||||
|
||||
- **Finding:** On a chain-exhausted response, `executeWithFallback` returns `providerUsed: chain[0].provider` (the configured primary). At v0.1 this is always equivalent to "the first provider whose plugin spawned" because soft triggers are deferred per Amendment 2 — every hop is attempted in order. When soft triggers reactivate in v1.x, the equivalence can break: a soft-skipped hop 0 followed by hard-failed hops 1+N would still report `providerUsed=chain[0]` even though chain[0]'s `spawn()` was never called. The README description "which provider's plugin **served** the request" is technically false in this latent edge case. GitHub issue #8 tracked the ambiguity.
|
||||
- **Decision — Option B (document chain-origin semantics):** v0.1 keeps the chain-origin contract. `X-OLP-Provider-Used` on a chain-exhausted response identifies **the chain's configured primary entry** (`chain[0].provider`), not necessarily the first hop where `spawn()` was actually invoked. Rationale:
|
||||
- At v0.1 the distinction is unobservable (soft triggers are dead-by-config per Amendment 2). Switching to Option A — track `firstAttemptedProvider` separately and return that — would add state to `executeWithFallback` for an unreachable v0.1 code path, violating ALIGNMENT.md Rule 2 (No Invention).
|
||||
- The chain-origin framing matches the existing `fallback_hops` semantics: a request that exhausts a 3-hop chain reports `fallbackHops=3`, indicating "the configured chain ran end-to-end." `providerUsed=chain[0]` aligns with that framing as "the primary the user configured for this request."
|
||||
- The new `X-OLP-Fallback-Detail` header (Amendment 5 / D40) carries per-hop attribution including soft-skip records (`trigger_type: 'soft'`), so the precise spawn history is recoverable from the wire without needing `providerUsed` to disambiguate.
|
||||
- **Implementation:**
|
||||
- `lib/fallback/engine.mjs` chain-exhausted return site gains a comment block explicitly citing this amendment and the v0.1-vs-v1.x semantic.
|
||||
- README "Observability headers" / "API surface" sections updated: replace "which provider's plugin **served** the request" with "the chain's primary entry (configured provider for this request)."
|
||||
- **v1.x re-evaluation:** When soft triggers reactivate (the v1.x work tracked in Amendment 2), this amendment should be revisited. Option A may become preferable as part of the soft-trigger reactivation PR — the implementer can track `firstAttemptedProvider` alongside the existing `triedProviders` state and switch the chain-exhausted `providerUsed` to that. If chosen, the README + this amendment need a coordinated update.
|
||||
- **No code-behavior change.** No package.json bump (phase_rolling_mode). No new tests at D41 — the relevant behavior is dead-by-config; future v1.x soft-trigger reactivation should add a test that exercises the soft-skip + chain-exhausted edge case and pins whichever option the v1.x maintainer chooses.
|
||||
- **Authority:** § Decision § Chain advancement step 4 (return the original first-hop error on exhaustion — Amendment 6 disambiguates "first-hop" as chain-origin); Amendment 2 (soft triggers deferred — the precondition for this edge case being unreachable at v0.1); Amendment 5 (per-hop attribution via fallbackDetail provides the disambiguation channel); ALIGNMENT.md Rule 2 (No Invention — rationale for not adding `firstAttemptedProvider` tracking today); GitHub issue #8 — closed by this commit.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D41, doc-only — no fresh-context reviewer required for documentation-only amendments per Iron Rule 10's implementation-phase scope).
|
||||
|
||||
### Amendment 5 — 2026-05-24: `X-OLP-Fallback-Detail` header shipped as ungated v0.1 (D40, issue #7)
|
||||
|
||||
- **Finding:** Step 4 of § Decision § Chain advancement (below) promised "per-provider failure detail in a debug header `X-OLP-Fallback-Detail` (JSON, owner-only — gated behind a config flag for non-owner keys)." From D9 through D39 the engine logged per-hop failure events via `fallback_hop_error` / `fallback_hard_trigger` / `fallback_client_error_no_fallback` / `fallback_auth_missing_no_fallback` / `fallback_non_trigger_error` (D28 added the `chain_id` / `trigger_type` / `ir_request_hash` / `next_provider` correlation fields), but the per-hop failure trail was not surfaced on the response. GitHub issue #7 tracked the gap.
|
||||
- **Change (D40):**
|
||||
- `lib/fallback/engine.mjs#executeWithFallback` now collects per-hop failure tuples in a new `fallbackDetail` array on the returned `FallbackResult`. Tuple shape reuses D28 log-event field shapes so logs and the header pivot on the same keys:
|
||||
`{ hop, provider, model, code, error_message, trigger_type }`. `code` is the `ProviderError` code, or any string `err.code` (including the engine-synthetic `SOFT_TRIGGER`), or `'UNKNOWN'` for non-`ProviderError` exceptions. `error_message` is truncated to 200 chars (single-character ellipsis `…` appended on truncation). `trigger_type` is the same classification surfaced in the D28 log events.
|
||||
- `server.mjs` emits the new header `X-OLP-Fallback-Detail: <JSON-stringified array>` on any response where `fallbackDetail` is non-empty — i.e., chain-exhausted, non-trigger-error, client-error, AUTH_MISSING, and success-with-prior-failure paths. Header is absent on clean primary success (semantically: no failure trail to report).
|
||||
- 4KB UTF-8 byte cap on the header value: if the serialised array exceeds 4096 bytes, tail tuples are dropped one at a time and a `{ truncated: true, omitted_hops: N }` sentinel is appended such that the total fits under the cap.
|
||||
- Non-ASCII characters in tuple fields (e.g. the em dash in the synthesised `CONCURRENCY_LIMIT` error message) are escaped as `\uXXXX` to satisfy RFC 7230 §3.2.6 `field-vchar` (Node's HTTP header validator rejects multi-byte UTF-8). `JSON.parse` round-trips the escaped form correctly.
|
||||
- **Gating — Option A (ungated v0.1):** The original promise specified owner-only gating. Per the maintainer decision recorded in issue #7, v0.1 ships the header **ungated**: the failure detail is surfaced on every response regardless of API key identity. Rationale: OLP v0.1 is single-tenant family-scale (per ALIGNMENT.md § What this project is); no PII risk in error details. **Phase 2 will re-introduce owner-vs-non-owner gating when `lib/keys.mjs` lands** — this is an explicit follow-up tracked in AGENTS.md § Key files to know and in the lib/keys.mjs Phase 2 planning. Until then, the header is informational on every response and operators should not assume per-key visibility differs.
|
||||
- **Authority:** § Decision § Chain advancement step 4 (original promise — D40 fulfils it); D18 (5 standard X-OLP-* headers; D40 builds on this convention); D28 (per-hop structured log fields; D40 reuses the field shapes); GitHub issue #7 — closed by this commit.
|
||||
- **Tests (test-features.mjs):** New describe block "D40 — X-OLP-Fallback-Detail header (issue #7)" covers: engine-level tuple shape on 2-hop/exhausted, 2-hop/success-with-prior-failure, 1-hop/success (empty array), 1-hop/fail, non-ProviderError-yields-`UNKNOWN`, 500-char-message → 200-char-with-ellipsis, client error → 1 tuple + `client_error` trigger type; serialiser-level empty/null → null, small-array round-trip, >4KB cap with `{truncated:true,omitted_hops:N}` sentinel, RFC 7230 newline/CR escaping, and non-ASCII escaping (em dash regression guard for the D38 `CONCURRENCY_LIMIT` synthesised message); HTTP integration covers clean-1-hop-success (header absent), 2-hop-exhausted (2 tuples on the wire), and 2-hop-success-with-prior-failure (1 tuple on the wire). Test count 452 → 468 (16 new tests).
|
||||
- **v1.x re-evaluation triggers:**
|
||||
- When `lib/keys.mjs` lands (Phase 2), re-introduce owner-vs-non-owner gating. Update this amendment + § Observability headers below + AGENTS.md.
|
||||
- If a future debug-header field becomes useful (e.g., `attempts`, `cache_eviction_count`, `last_chunk_index`), add to the tuple schema documented above + bump this amendment + extend the test schema assertions.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D40 issue #7 implementation; fresh-context opus reviewer to follow per Iron Rule 10).
|
||||
|
||||
### Amendment 4 — 2026-05-24: Add `CONCURRENCY_LIMIT` to v0.1 hard-trigger code taxonomy (D38, issue #1)
|
||||
|
||||
- **Finding:** ADR 0002 Amendment 1 (2026-05-23) ratified `hints.maxConcurrent` into the Provider contract as **declarative-only at v0.1** — no runtime enforcement. GitHub issue #1 tracked the gap. D38 lands runtime enforcement (see ADR 0002 Amendment 6 for the implementation details and design rationale). Once a saturation event occurs, the orchestration layer must communicate "this hop is at capacity — advance the chain" to the fallback engine using a code that fits the existing hard-trigger taxonomy in `evaluateHardTriggers`.
|
||||
- **Change (D38):** Add `CONCURRENCY_LIMIT` to both:
|
||||
- `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` (closed enum used by `ProviderError`).
|
||||
- `HARD_TRIGGER_CODES` in `lib/fallback/engine.mjs` — value `true` so `evaluateHardTriggers(ProviderError CONCURRENCY_LIMIT)` returns `true` and `classifyTrigger` returns `'hard'`. The chain advances to the next hop. The synthesised error carries diagnostic fields (`providerName`, `maxConcurrent`, `activeSpawns`) which surface in the existing `fallback_hard_trigger` log event via the `error.message` field.
|
||||
- **v0.1 live hard-trigger codes after this amendment (5 codes):** `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`, `CONCURRENCY_LIMIT`, plus the explicit non-trigger `AUTH_MISSING:false`. Pre-D38 list (per Amendment 3) was 4 codes (`SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT` as triggers + `AUTH_MISSING:false`).
|
||||
- **Synthesis vs. plugin-thrown:** Unlike the other live hard-trigger codes, `CONCURRENCY_LIMIT` is **NOT thrown by provider plugins themselves**. It is synthesised by `server.mjs handleChatCompletions` when `tryAcquireSpawn(provider, hints.maxConcurrent)` returns false. The code lives in `PROVIDER_ERROR_CODES` for type consistency with the closed enum that `HARD_TRIGGER_CODES` keys on; the orchestration layer is the only callsite that throws it. A future provider plugin that gains its own internal concurrency limit (e.g., a CLI that returns a specific exit code on rate-limit) could thrown this code too; the enum is forward-compatible.
|
||||
- **Design choice — immediate-advancement vs. queue+timeout:** Re-stating from ADR 0002 Amendment 6 because this ADR governs the trigger taxonomy that surfaces the decision to the user: saturation is treated as a **hard trigger** (chain advances immediately) rather than as a **soft trigger** (would gate before spawn but would not advance after spawn attempt) or as a queueable condition (would block + timeout). The hard-trigger framing matches "the primary hop refused to serve this request; advance" semantics. Queue+timeout would require a NEW trigger category outside the existing taxonomy (hard / soft / deterministic-deferred / cost-aware-deferred) and is deferred per ADR 0002 Amendment 6 rationale.
|
||||
- **First-chunk safety:** `tryAcquireSpawn` runs **before** `provider.spawn(...)` and before any bytes are written to the response. A `CONCURRENCY_LIMIT` rejection therefore satisfies the first-chunk rule trivially — zero bytes have been emitted to the client. Fallback is safe.
|
||||
- **Authority:** ADR 0002 Amendment 6 (runtime enforcement implementation); GitHub issue #1 (tracking).
|
||||
- **Tests:** Suite 18 in `test-features.mjs` — see ADR 0002 Amendment 6 § Tests for the full list. Specifically for this ADR: tests 18a (PROVIDER_ERROR_CODES membership), 18b (`evaluateHardTriggers(CONCURRENCY_LIMIT) === true`), 18c (AUTH_MISSING regression guard — D38 did not flip it), 18k (chain advances to fallback hop on saturated primary).
|
||||
- **v1.x re-evaluation triggers:**
|
||||
- If a future plugin gains a CLI-level concurrency response that should NOT be a hard trigger (e.g., "soft limit hit, retry after backoff") — file a follow-up to add a new code (e.g., `CONCURRENCY_BACKOFF`) rather than reclassifying `CONCURRENCY_LIMIT`.
|
||||
- If queue+timeout becomes desirable (real usage shows fail-fast advancement is too aggressive for certain workloads), file an amendment to this ADR adding queue semantics as a NEW trigger category — do not reclassify CONCURRENCY_LIMIT into the existing taxonomy.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (D38 issue #1 implementation; fresh-context opus reviewer to follow per Iron Rule 10).
|
||||
|
||||
### Amendment 3 — 2026-05-24: Narrow v0.1 hard-trigger code taxonomy (D34 F7)
|
||||
|
||||
- **Finding:** Round-6 cold-audit F7 (P2) — `PROVIDER_ERROR_CODES` in `lib/providers/base.mjs` and `HARD_TRIGGER_CODES` in `lib/fallback/engine.mjs` both listed `QUOTA_EXHAUSTED` and `RATE_LIMITED` as live hard-trigger codes. No v0.1 plugin emits either code. The Anthropic, Codex, and Mistral plugins all use `claude -p`, `codex exec --json`, and `vibe --prompt` respectively — none parse the underlying-API HTTP response status code or surface a structured quota/rate error; they only throw `SPAWN_FAILED`, `SPAWN_TIMEOUT`, `CLI_NOT_FOUND`, or `AUTH_MISSING`. The two `Hard triggers` bullets in § Trigger taxonomy ("HTTP 5xx from provider's underlying API" and "HTTP 4xx quota exhaustion") are therefore unreachable through the `ProviderError` code path at v0.1.
|
||||
@@ -15,7 +64,7 @@
|
||||
- `QUOTA_EXHAUSTED` and `RATE_LIMITED` removed from `PROVIDER_ERROR_CODES` (base.mjs) and `HARD_TRIGGER_CODES` (engine.mjs). Dead code removal.
|
||||
- A comment block added in `evaluateHardTriggers` labeling the HTTP-status branches as "forward-compat reserved — v0.1 plugins never attach statusCode."
|
||||
- Test coverage: the two unit tests for `evaluateHardTriggers: ProviderError QUOTA_EXHAUSTED/RATE_LIMITED → fires` are removed (tombstoned with a removal comment). All other hard-trigger tests that used these codes as convenient test vectors are rewritten to use `SPAWN_FAILED` / `SPAWN_TIMEOUT`.
|
||||
- **v0.1 live hard-trigger codes after this amendment:** `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`. `AUTH_MISSING` remains in the table as an explicit `false` entry (deliberate non-trigger per ADR 0004 § "No fallback for client-side errors" analogue).
|
||||
- **v0.1 live hard-trigger codes after this amendment:** `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`. `AUTH_MISSING` remains in the table as an explicit `false` entry (deliberate non-trigger per ADR 0004 § "No fallback for client-side errors" analogue). **Subsequently extended by Amendment 4 (D38) — `CONCURRENCY_LIMIT` added as a 4th true entry; the v0.1 live-codes list as of D38 is `SPAWN_FAILED`, `CLI_NOT_FOUND`, `SPAWN_TIMEOUT`, `CONCURRENCY_LIMIT` plus the explicit `AUTH_MISSING:false` non-trigger entry.**
|
||||
- **v1.x re-activation path:** When a plugin gains HTTP-status parsing (e.g., an Anthropic plugin variant that makes direct Messages API calls rather than spawning `claude -p`), add the plugin-layer HTTP parsing, re-add `QUOTA_EXHAUSTED` and `RATE_LIMITED` to both tables, and amend this entry. The `evaluateHardTriggers` HTTP-status branches will then activate naturally with no further engine changes.
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-6 Cold Audit).
|
||||
|
||||
@@ -35,6 +84,26 @@
|
||||
|
||||
- **Streaming path note:** The D10 real-streaming branch (single-hop, `server.mjs` lines 401–510) already handles the analogous case correctly via ADR 0004's first-chunk rule: once `firstChunkEmitted === true`, any subsequent error truncates the response with `res.end()` (no re-throw, no fallback). This amendment applies specifically to the **buffered path** (`collectAllChunks` + multi-hop fallback chains). The streaming path is not changed by D16.
|
||||
|
||||
#### D39 follow-up — explicit eviction primitive + observability log (2026-05-24, issue #3 Parts 1+2)
|
||||
|
||||
The original D16 cache-eviction implementation used `cacheStore.set(keyId, hopCacheKey, result, ttlMs=0)` to tombstone the just-written truncated entry. The TTL=0 entry survived in the per-keyId namespace `Map` until the next `get`/`peek` lazily purged it via the `_isAlive` check. D39 Part 1 replaces this with an explicit `cacheStore.delete(keyId, cacheKey)` primitive that (a) removes the entry from the namespace `Map` immediately, (b) removes the empty namespace `Map` entry from the outer store when it becomes empty (memory hygiene matching the D38 `_activeSpawns` pattern), and (c) returns `boolean` for caller inspection. D39 Part 2 adds a `cache_evicted_truncated` `info`-level log event with `{ provider, model }` fields immediately after the eviction, giving dashboards visibility into salvage frequency. Neither change alters the salvage semantics established by this Amendment — they are observability + memory-hygiene polish. Test coverage: 3 unit tests on `CacheStore.delete` (present-returns-true, absent-returns-false, empty-namespace-cleanup), 1 HTTP integration test asserting the log event fires with the correct fields, and 1 defense-in-depth regression test asserting two consecutive identical truncated requests both result in fresh spawns (no sticky cache).
|
||||
|
||||
#### Why SPAWN_TIMEOUT is excluded from salvage (D39 Part 4, issue #3 Part 4)
|
||||
|
||||
D16's salvage path is gated on `code === 'SPAWN_FAILED'`. SPAWN_TIMEOUT is **not** salvaged even when partial chunks have accumulated in the buffered path — the timeout error propagates from `collectAllChunks` as-is, the fallback engine fires the SPAWN_TIMEOUT hard trigger, and the chain advances to the next hop. This asymmetry is intentional and is the maintainer's design choice. The four-point rationale:
|
||||
|
||||
1. **SPAWN_FAILED is a terminal signal from this hop.** The provider crashed mid-stream; nothing more is coming from it. Salvaging the partial chunks is strictly better than discarding them (partial > nothing). Advancing the chain in this case offers no advantage: the same input may crash the next hop the same way (when the failure is input-dependent), and even when the next hop succeeds, the salvaged chunks were already paid for in quota — discarding them would be strict waste.
|
||||
|
||||
2. **SPAWN_TIMEOUT is a deadline signal, not a terminal signal.** It indicates the provider was slow (deadline exceeded per `hints.maxSpawnTimeMs`, which the plugin enforces — see the unconditional post-loop `if (spawnTimedOut) throw SPAWN_TIMEOUT` in each provider plugin, e.g. `lib/providers/anthropic.mjs`). The next hop is a *different provider* with different model-speed characteristics, so its full response is plausibly available sooner than the original hop's continuation would have been. Fallback advancement on timeout is more likely to give the user a complete response than salvaging partial-from-slow.
|
||||
|
||||
3. **The "user paid for partial" framing applies only to SPAWN_FAILED.** The D16 reviewer's "user paid for partial content, dropping it is strict waste" captures SPAWN_FAILED correctly: the deadline was honored, the provider died mid-stream, the chunks are real consumed quota. For SPAWN_TIMEOUT the user actually paid for "result within time T" — a partial result delivered *at* time T is not what was paid for. The fallback engine's "full result soon after time T" via a different provider is closer to the contract.
|
||||
|
||||
4. **Code-level inspection confirms the asymmetry (verified post-D38, D39 Part 4).** `collectAllChunks` in `server.mjs` matches only `spawnErr instanceof ProviderError && spawnErr.code === 'SPAWN_FAILED' && chunks.length > 0` for the salvage branch. SPAWN_TIMEOUT propagates through the same catch block via the unconditional re-throw, hits `evaluateHardTriggers` as a hard trigger (per Amendment 3: SPAWN_FAILED, CLI_NOT_FOUND, SPAWN_TIMEOUT; per Amendment 4: CONCURRENCY_LIMIT), and advances the chain. This asymmetry is not an oversight; it is the design.
|
||||
|
||||
**Hard-trigger taxonomy completeness:** The v0.1 hard-trigger code set is enumerated in Amendment 3 (D34 F7) and extended in Amendment 4 (D38, CONCURRENCY_LIMIT). Of those four codes, only SPAWN_FAILED participates in the salvage path. CLI_NOT_FOUND fires before any spawn output is possible (no partial chunks ever exist). CONCURRENCY_LIMIT fires before `provider.spawn(...)` is called (per Amendment 4 § First-chunk safety — zero bytes emitted at rejection moment). SPAWN_TIMEOUT can in principle accumulate partial chunks but is excluded from salvage per the rationale above.
|
||||
|
||||
**v1.x re-evaluation trigger:** If real usage shows users want partial-on-timeout for very long deadlines (e.g., a 5-minute `maxSpawnTimeMs` where the user would rather have whatever streamed in 5 minutes than re-pay quota on a different provider that may take its own 5 minutes), this asymmetry is queued as a future-design question. A v1.x amendment would need to: (a) make salvage-on-timeout opt-in per chain or per provider (default-off preserves v0.1 semantics), (b) extend `collectAllChunks` catch matching to a broader code set, (c) add tests parallel to the D16 Case A/Case B/single-hop trio for the SPAWN_TIMEOUT path. Not a v0.1 issue.
|
||||
|
||||
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Cold Audit). Diff-review reviewers on earlier passes focused on the first-chunk rule for the real-streaming path; the buffered path has its own truncation-vs-fallback decision point, which the cold-audit pass on 2026-05-23 identified as Finding 17.
|
||||
|
||||
### Amendment 2 — 2026-05-24: Soft triggers deferred to v1.x (D22)
|
||||
@@ -104,7 +173,7 @@ This is non-negotiable for v1.0. Post-first-chunk truncations are surfaced to th
|
||||
1. Try A. If A succeeds, return; emit `X-OLP-Fallback-Hops: 0`, `X-OLP-Provider-Used: A`.
|
||||
2. If A's failure matches a hard or soft trigger AND no chunks emitted: try B. If B succeeds, return; emit `X-OLP-Fallback-Hops: 1`, `X-OLP-Provider-Used: B`.
|
||||
3. If B also fails: try C. Same logic.
|
||||
4. If all of A, B, C fail: return **A's original error** (not B's, not C's) with `X-OLP-Fallback-Exhausted: A,B,C` listing the chain order, and per-provider failure detail in a debug header `X-OLP-Fallback-Detail` (JSON, owner-only — gated behind a config flag for non-owner keys).
|
||||
4. If all of A, B, C fail: return **A's original error** (not B's, not C's) with `X-OLP-Fallback-Exhausted: A,B,C` listing the chain order, and per-provider failure detail in a debug header `X-OLP-Fallback-Detail` (JSON, ungated at v0.1 per Amendment 5 / D40, issue #7; owner-vs-non-owner gating planned for Phase 2 when `lib/keys.mjs` lands). The header is also emitted on success-with-prior-failure paths (e.g., A fails + B succeeds → response carries the 1-tuple failure trail for A). See § Observability headers below for the tuple schema and cap behaviour.
|
||||
|
||||
**Observability headers (per spec §4.7).**
|
||||
- `X-OLP-Provider-Used: <provider-name>`
|
||||
@@ -112,6 +181,12 @@ This is non-negotiable for v1.0. Post-first-chunk truncations are surfaced to th
|
||||
- `X-OLP-Fallback-Hops: <integer ≥ 0>`
|
||||
- `X-OLP-Cache: hit | miss | bypass`
|
||||
- `X-OLP-Latency-Ms: <integer>`
|
||||
- `X-OLP-Fallback-Exhausted: <comma-separated provider list>` — emitted only when multiple providers were tried (D18; chain-exhaustion path).
|
||||
- `X-OLP-Fallback-Detail: <JSON array>` — **shipped as IMPLEMENTED at v0.1, ungated** per Amendment 5 (D40, issue #7). Emitted on any response where at least one prior hop failed before the chain resolved or exhausted; absent on clean primary success.
|
||||
- **Tuple schema (per failed hop):** `{ hop: <0-indexed integer>, provider: <string>, model: <string>, code: <ProviderError.code or 'UNKNOWN'>, error_message: <string truncated to 200 chars with U+2026 ellipsis on truncation>, trigger_type: 'hard' | 'soft' | 'auth_missing' | 'client_error' | 'non_trigger' }`. Field shapes reuse D28's per-hop log event keys.
|
||||
- **4KB UTF-8 byte cap:** if the JSON-stringified array exceeds 4096 bytes, tail tuples are dropped and a `{ truncated: true, omitted_hops: <count> }` sentinel is appended so the total fits under the cap.
|
||||
- **RFC 7230 hygiene:** all non-ASCII code points are escaped as `\uXXXX` so the header value is pure ASCII (Node's HTTP header validator rejects multi-byte UTF-8). `JSON.parse` still round-trips the escaped form to the original Unicode.
|
||||
- **Phase 2 follow-up:** owner-vs-non-owner gating is planned for when `lib/keys.mjs` lands. Until then, the header is informational on every response. See Amendment 5 for the full rationale and the gating re-introduction trigger.
|
||||
|
||||
Each fallback hop emits a structured log event with: timestamp, chain id, hop index, failed provider, trigger type, IR request hash, downstream provider that was tried next.
|
||||
|
||||
|
||||
@@ -7,6 +7,139 @@
|
||||
|
||||
## Amendments
|
||||
|
||||
### Amendment 8 — 2026-05-25: Streaming singleflight — v1.x design ratification (D42, issue #16)
|
||||
|
||||
**Status:** Design ratified. Implementation deferred to v1.x.
|
||||
|
||||
**Context.** Amendment 6 (D34) formally deferred streaming-path D4 singleflight participation with the note "the design alone warrants a dedicated ADR." Round-6 cold-audit F13 (filed as issue #16) raised the sibling TOCTOU window: `server.mjs:782 preCheckHit = await cacheStore.peek(...)` followed by the streaming-branch entry conditionals at lines 817–823 (the TODO anchor sits just above at line ~810 and is the navigable landmark; line numbers may drift across commits) creates a race where, between peek and spawn, a concurrent populator can write the cache OR a TTL can expire. The streaming branch is path-locked at the moment of the peek result.
|
||||
|
||||
This amendment ratifies the v1.x design so the implementation work has a single specification to follow.
|
||||
|
||||
**Design — per-(keyId, cacheKey) inflight Map + tee-streaming + bounded per-client backpressure.**
|
||||
|
||||
1. **Coordination primitive.** Extend `CacheStore` with `getOrComputeStreaming(keyId, cacheKey, sourceFactory): { stream: AsyncIterator<IRChunk>, isFirst: boolean }`. Internally maintains `_streamingInflight: Map<compositeKey, StreamingInflightEntry>`. Three outcomes on call:
|
||||
- **Cache hit** (cached entry exists and is alive): synthesize an async iterator that yields the cached chunks. `isFirst = false`. No spawn.
|
||||
- **Inflight join** (entry exists in `_streamingInflight`): attach a new `AttachedClient` to the existing entry. `isFirst = false`. No spawn.
|
||||
- **Cache miss + no inflight**: create a new `StreamingInflightEntry`, invoke `sourceFactory()` to obtain the underlying spawn iterator, attach as the source. `isFirst = true`. Subsequent identical-key callers join this entry until it completes or aborts.
|
||||
|
||||
The Map check + insert is synchronous (no `await` between read and write), matching the D38 `tryAcquireSpawn` atomicity invariant. Document this in the implementation header.
|
||||
|
||||
2. **StreamingInflightEntry shape.**
|
||||
|
||||
```
|
||||
{
|
||||
compositeKey: string, // keyId + '\0' + cacheKey
|
||||
source: AsyncIterator<IRChunk>,
|
||||
sourceAbortController: AbortController,
|
||||
accumulatedChunks: IRChunk[], // for late joiners (replay buffer)
|
||||
attachedClients: Set<AttachedClient>,
|
||||
sourceDone: boolean, // source iterator exhausted
|
||||
sourceError: Error | null, // non-null if source threw
|
||||
sourceAborted: boolean, // true if AbortController fired
|
||||
spawnAcquiredProvider: string | null, // for D38 release coordination
|
||||
}
|
||||
```
|
||||
|
||||
3. **AttachedClient shape.**
|
||||
|
||||
```
|
||||
{
|
||||
id: string, // request ID (D40 fallback log correlator)
|
||||
queue: IRChunk[], // per-client tee buffer
|
||||
queueByteSize: number, // running sum of JSON.stringify(chunk).length for cap
|
||||
yieldedAccumulated: boolean, // true after late-joiner replay drained
|
||||
done: boolean,
|
||||
resolveNext: ((chunk) => void) | null, // promise resolver for the next chunk
|
||||
rejectNext: ((err) => void) | null,
|
||||
}
|
||||
```
|
||||
|
||||
4. **Tee fan-out loop (single-reader, multi-writer).** Source iterator is drained by ONE reader (the entry's tee task), which on each chunk:
|
||||
- Pushes the chunk into `accumulatedChunks` (late-joiner replay buffer; bounded — see §10).
|
||||
- For each `client ∈ attachedClients`: if `client.queueByteSize + chunkSize > PER_CLIENT_QUEUE_CAP` (default 1 MB), the client is disconnected with `STREAM_BACKPRESSURE` (see §8). Otherwise push the chunk into `client.queue`, update `queueByteSize`, fire `resolveNext` if pending.
|
||||
|
||||
When the source iterator returns/throws/aborts, the tee task:
|
||||
- On normal completion: writes `accumulatedChunks` to cache via the standard cache-write conditions — `truncated-not-cached` from § Decision § "Cache write conditions" item 1; `cacheable=false` opt-out from Amendment 3; `claude -p --output-format text` wire-shape limitation from Amendment 5; size cap from Amendment 3. (Note: ADR 0005 has no Amendment 1 heading — the section §-Decision body item-1 is the source for `truncated-not-cached`, NOT a numbered amendment.) Resolves all clients' `resolveNext` with their remaining queue then sentinel-marks `done`. Releases the D38 spawn slot once. Removes the entry from `_streamingInflight`.
|
||||
- On source error: rejects all clients with the error. Does NOT write cache. Releases the D38 spawn slot. Removes entry.
|
||||
- On source abort (all clients disconnected): cancels the iterator via AbortController, releases the slot, removes entry. No cache write (partial response not persisted, matches D16 buffered-path SPAWN_FAILED salvage NOT applying to abort).
|
||||
|
||||
5. **Late-joiner policy.** When a new client attaches mid-stream:
|
||||
- Drain `accumulatedChunks` into the client's queue immediately (synchronous burst).
|
||||
- If the burst exceeds `PER_CLIENT_QUEUE_CAP`, the client is rejected immediately with `STREAM_BACKPRESSURE` (the implication is that the source has produced more than 1 MB before this client attached — late joiner is too late to catch up).
|
||||
- From that point on, the client receives live chunks via the tee loop.
|
||||
|
||||
6. **Cache TTL race during inflight.** If a cache entry is alive at peek time but expires during the inflight period, late joiners that arrive AFTER expiry still see the inflight entry in `_streamingInflight` (Map lookup precedes cache peek per the new contract). They attach via inflight join. No fresh spawn. The expired cache entry is overwritten by the inflight completion.
|
||||
|
||||
7. **D38 maxConcurrent coordination.** Only the first caller's source-spawn calls `tryAcquireSpawn`. Subsequent attached clients DO NOT call it — they share the existing spawn slot. On source completion / error / abort, `releaseSpawn` fires once. If `tryAcquireSpawn` returns false for the first caller, the request fails with `CONCURRENCY_LIMIT` per D38 (existing behavior) and the streaming branch is not entered.
|
||||
|
||||
8. **Backpressure error code.** New `PROVIDER_ERROR_CODES.STREAM_BACKPRESSURE`. **NOT a hard trigger** — the source spawned successfully; only one client's queue overflowed. The affected client receives a synthetic `{ type: 'stop', finish_reason: 'length' }` followed by `[DONE]` (matching D35 #10 truncation marker pattern). Server logs `stream_backpressure_disconnect` with `{ provider, model, client_id, queue_byte_size, per_client_cap }`. Other attached clients continue receiving chunks normally.
|
||||
|
||||
9. **Client mid-stream disconnect (network drop / abort).** The HTTP response stream's `close` event triggers client cleanup: remove from `attachedClients`, no fallback advancement (the source is still running for other clients). If `attachedClients.size === 0`, the tee task fires `sourceAbortController.abort()` (which propagates to the underlying CLI spawn — D38's plugin spawn loops already handle AbortController per ADR 0002 § Provider contract).
|
||||
|
||||
10. **Replay buffer cap.** `accumulatedChunks` is bounded at `ACCUMULATED_REPLAY_CAP` (default 10 MB, matches the existing cache-entry size cap from D23). If the source produces more than the cap before completion, the entry is marked NOT cacheable (cache write skipped at source-complete). Late joiners attaching past the cap receive `STREAM_BACKPRESSURE` immediately (the replay burst would exceed `PER_CLIENT_QUEUE_CAP`). First caller's stream continues unaffected because they were attached before the cap was hit.
|
||||
|
||||
11. **Observability.** New log events:
|
||||
- `streaming_inflight_join` — fires when a request attaches to an existing inflight entry. Fields: `{ provider, model, attached_count_after, accumulated_chunk_count }`.
|
||||
- `streaming_inflight_source_done` — fires when the source completes. Fields: `{ provider, model, attached_count, accumulated_chunk_count, cache_written }`.
|
||||
- `stream_backpressure_disconnect` — see §8.
|
||||
- `streaming_inflight_abort` — fires when all clients disconnect and source is aborted. Fields: `{ provider, model, accumulated_chunk_count }`.
|
||||
|
||||
New X-OLP-* header: `X-OLP-Streaming-Inflight: source | attached | solo` distinguishing which role this client played. `solo` = first caller, no joiners during stream (functionally equivalent to today's behavior). `source` = first caller, ≥1 joiner attached. `attached` = joined an existing inflight entry. Adds one field to the X-OLP-* set (currently 5); update D18-D40 documentation when implementation lands.
|
||||
|
||||
12. **Server.mjs wiring.** Replace the current streaming branch peek+spawn pattern (server.mjs:782 `preCheckHit = await cacheStore.peek(...)` and lines 811–817 conditional) with:
|
||||
```js
|
||||
const { stream, isFirst } = await cacheStore.getOrComputeStreaming(
|
||||
keyId,
|
||||
streamCacheKey,
|
||||
async () => {
|
||||
// sourceFactory: invoked only on first caller; encapsulates the D38
|
||||
// tryAcquireSpawn gate and provider.spawn invocation
|
||||
...
|
||||
}
|
||||
);
|
||||
```
|
||||
`isFirst` plumbed into the X-OLP-Streaming-Inflight header. The TOCTOU window collapses because Map check + insert is synchronous.
|
||||
|
||||
13. **Test surface (when implementation lands).** At minimum:
|
||||
- Single client streaming (`isFirst=true`, no joiners) — behavior identical to today.
|
||||
- 2 concurrent identical streams — only 1 spawn (`getActiveSpawnCount` returns 1 at the spawn peak); both clients receive identical chunk sequences in order.
|
||||
- 3 concurrent, mid-stream join — client 2 attaches mid-stream, receives accumulated burst + live tail; client 3 attaches after source-complete, served from cache.
|
||||
- First-client disconnect mid-stream, clients 2/3 continue, source NOT aborted.
|
||||
- All clients disconnect mid-stream → source aborted (AbortController fired), no cache write.
|
||||
- Source errors mid-stream → all attached clients receive the error; no cache write.
|
||||
- Backpressure: slow client → `PER_CLIENT_QUEUE_CAP` exceeded → `STREAM_BACKPRESSURE` disconnect with `finish_reason: 'length'`; other clients unaffected.
|
||||
- D38 semaphore: 2 concurrent identical streams hitting `maxConcurrent=1` — first spawns, second JOINS (does not get CONCURRENCY_LIMIT). 3 concurrent DIFFERENT streams hitting `maxConcurrent=2` — first 2 spawn, third gets CONCURRENCY_LIMIT (existing D38 path).
|
||||
- Cache TTL race: entry expires during inflight; late joiner attaches via inflight join; inflight completion overwrites the expired cache slot.
|
||||
- Replay buffer cap: source produces > `ACCUMULATED_REPLAY_CAP`; entry marked not cacheable; late joiner past cap gets `STREAM_BACKPRESSURE`; first caller stream unaffected.
|
||||
- X-OLP-Streaming-Inflight header values across all the above scenarios.
|
||||
|
||||
14. **Defaults to ratify in implementation.** `PER_CLIENT_QUEUE_CAP = 1 MB`, `ACCUMULATED_REPLAY_CAP = 10 MB` (matches D23 cache-entry size cap), `STREAM_BACKPRESSURE` not in `HARD_TRIGGER_CODES`. These are starting points; v1.x implementation may tune based on real-world latency profiles.
|
||||
|
||||
**Issue #16 status.** Stays OPEN as the v1.x implementation tracker. The issue body should be updated post-D42 to reference this amendment and adjust scope ("design ratified; implementation pending"). DO NOT close issue #16 until §13's test surface is green on the actual implementation.
|
||||
|
||||
**Cross-references and safeguards (so the implementation is not forgotten):**
|
||||
- `docs/v1x-roadmap.md` (new at D42) — single landing page for all v1.x deferrals, with streaming SF as item #1. Each entry cross-references the relevant ADR amendment and the GitHub issue.
|
||||
- `lib/cache/store.mjs` — TODO comment near `getOrCompute` pointing at this amendment for the streaming sibling API.
|
||||
- `server.mjs` — TODO comment near the streaming branch entry (line ~810) pointing at this amendment + issue #16 with the words "ADR 0005 Amendment 8 — v1.x".
|
||||
- `README.md § Known limitations` — line item exposing this to users (current behavior: each concurrent identical streaming request spawns its own CLI).
|
||||
- This amendment is item #1 in the next session-start handoff if the maintainer opens a v1.x sprint.
|
||||
|
||||
**Why this is the right shape (rationale):**
|
||||
- Mirrors the D4 buffered-path singleflight (`getOrCompute`) pattern, keeping the cache API surface coherent rather than fragmenting into two parallel coordination primitives.
|
||||
- Reuses D38 `tryAcquireSpawn` semantics for the first-caller path; attached callers naturally don't consume slots.
|
||||
- Late-joiner replay via `accumulatedChunks` resolves the case where a client arrives between source-spawn and source-complete without forcing it to wait for completion.
|
||||
- Bounded per-client queue protects against the "one slow client stalls the source" failure mode; the slow client gets a clean `STREAM_BACKPRESSURE` disconnect instead of corrupting the tee for other clients.
|
||||
- AbortController propagation ensures the source CLI process is reaped if all clients drop — no orphan processes consuming Anthropic/Codex/Mistral quota.
|
||||
|
||||
**Authority:**
|
||||
- Amendment 6 (D34 F1) — original deferral with "design alone warrants a dedicated ADR" note; this amendment is the dedicated ADR.
|
||||
- GitHub issue #16 (round-6 F13) — sibling TOCTOU window; same root cause.
|
||||
- ADR 0002 Amendment 6 (D38) — `tryAcquireSpawn` / `releaseSpawn` semantics that the §7 coordination builds on.
|
||||
- D40 Amendment 5 — per-hop observability pattern that §11 extends to streaming.
|
||||
- CC 开发铁律 v1.6 § 10.x — design ADR ratification; fresh-context reviewer not required for design-only amendments (no code change in D42).
|
||||
|
||||
**Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (design-only amendment per Iron Rule 10's implementation-phase scope — the implementation PR that lands this ADR's design will go through full Iron Rule 10 with a fresh-context opus reviewer at that time).
|
||||
|
||||
### Amendment 7 — 2026-05-24: Document cache-key-vs-CLI-args discrepancy as v0.1 conservative trade-off (D34 F8)
|
||||
|
||||
- **Finding:** Round-6 cold-audit F8 (P2) — Provider plugins (`lib/providers/anthropic.mjs`, `codex.mjs`, `mistral.mjs`) drop `temperature`, `max_tokens`, `top_p`, `stop`, `tools`, and `tool_choice` when spawning their respective CLIs. These CLIs (`claude -p`, `codex exec --json`, `vibe --prompt`) do not accept those flags. However, the cache key (per Amendment 2) includes all of them. Consequence: two requests that differ only in `temperature` produce identical CLI output (the CLI ignores it) but different cache keys → spurious miss. The caller pays the spawn cost twice for what is, at the CLI layer, the same request.
|
||||
|
||||
@@ -136,6 +136,38 @@ Each entry: `{ "id": "<model-id>", "object": "model", "created": <ts>, "owned_by
|
||||
no invented fields (per D27 F15). Alias entries are also surfaced as separate list members
|
||||
(per D27 F15 alias surfacing).
|
||||
|
||||
**Alias surfacing — controlled deviation (D36 #13).** OpenAI's `/v1/models` spec
|
||||
enumerates one entry per canonical model ID; OLP additionally surfaces alias entries
|
||||
(e.g. `claude`, `sonnet`, `opus`, `haiku` alongside their canonical Anthropic targets).
|
||||
This is a documented deviation from strict spec parity. It is governed by
|
||||
`ALIGNMENT.md § Class-specific Exceptions → Controlled deviations (entry-surface scope)`,
|
||||
which references this section as the formal contract.
|
||||
|
||||
The alias-entry contract:
|
||||
|
||||
| Field | Value for alias entry |
|
||||
|---|---|
|
||||
| `id` | the alias string (e.g. `'sonnet'`) — same shape as canonical entries |
|
||||
| `object` | `'model'` — same as canonical entries |
|
||||
| `created` | identical to the canonical target's `created` timestamp (per F12) |
|
||||
| `owned_by` | identical to the canonical target's `owned_by` (i.e. the provider key) |
|
||||
|
||||
The alias list is sourced from `models-registry.json` via `getAliasMap()` in
|
||||
`lib/providers/index.mjs` — the SPOT for alias-aware routing. `server.mjs handleModels`
|
||||
appends alias entries to the canonical list only when the alias's canonical target's
|
||||
provider is currently in `loadedProviders`. No fields beyond the four OpenAI-spec fields
|
||||
are added on alias entries.
|
||||
|
||||
**Rationale (D27 F15):** Onboarding gap. Clients configured with `model: 'sonnet'` (a
|
||||
common alias used by Anthropic's own CLI and OpenClaw-class tools) previously received
|
||||
an empty `/v1/models` response that did not surface the alias as a callable model id.
|
||||
Surfacing the alias makes the discovery loop usable for OpenAI-compatible clients with
|
||||
alias-aware UX.
|
||||
|
||||
**Forward path:** Annual audit (14 May) re-checks whether OpenAI has shipped a formal
|
||||
alias-listing extension to `/v1/models`. If so, OLP migrates the alias surface to that
|
||||
shape. If not, the deviation continues unchanged.
|
||||
|
||||
**`created` field stability (F12 round-5 cold-audit):** OpenAI spec treats `created` as a
|
||||
stable per-model attribute, not a request-time value. `server.mjs handleModels` uses
|
||||
`getModelCreated(modelId)` (from `lib/providers/index.mjs`) which reads the per-entry
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
# Anthropic provider — version-capture artifact
|
||||
|
||||
- **Provider key:** `anthropic`
|
||||
- **Plugin file:** `lib/providers/anthropic.mjs`
|
||||
- **Last capture:** 2026-05-24 (D36 #15)
|
||||
- **Capture host:** project maintainer's primary workstation (home-mac)
|
||||
- **Status:** living artifact — re-capture at every plugin touch, or annually
|
||||
during the 14 May Annual Alignment Audit, whichever comes first.
|
||||
|
||||
This artifact closes the circular-citation finding from Round-6 (issue #15):
|
||||
ALIGNMENT.md § Provider Authority Pins anthropic row cites `@anthropic-ai/claude-code`
|
||||
v2.1.89 (observed at D4) without an independent transcript; the plugin header cited
|
||||
the observation date but pointed back to ALIGNMENT.md. This file is the in-repo
|
||||
transcript artifact that grounds the OLP-side claim.
|
||||
|
||||
---
|
||||
|
||||
## Observed `claude --version`
|
||||
|
||||
The live binary on the maintainer's workstation today is:
|
||||
|
||||
```
|
||||
2.1.132 (Claude Code)
|
||||
```
|
||||
|
||||
Captured by running `claude --version` in a non-interactive shell on 2026-05-24.
|
||||
|
||||
## Plugin-pinned version
|
||||
|
||||
```
|
||||
@anthropic-ai/claude-code v2.1.89
|
||||
```
|
||||
|
||||
Source: ALIGNMENT.md § Authorities § "Provider authority pins" row `anthropic`
|
||||
("OLP-side pin: `@anthropic-ai/claude-code` v2.1.89 (observed at D4 —
|
||||
`lib/providers/anthropic.mjs` header)"). This is the version observed inside
|
||||
the OLP-side D4 work; the plugin was authored against this version's flag
|
||||
surface.
|
||||
|
||||
## Version drift note
|
||||
|
||||
The pinned version (v2.1.89, D4) and the live binary (v2.1.132, today) differ
|
||||
because the `claude` CLI has continued to ship updates since D4. This drift is
|
||||
within tolerance for the v0.1 baseline:
|
||||
|
||||
- The CLI flags OLP consumes — `-p`, `--output-format=text`,
|
||||
`--no-session-persistence`, `--model`, `--debug` — are all still present and
|
||||
semantically unchanged in v2.1.132 (verified today via `claude -p --help`
|
||||
— see "Flag surface captured today" below).
|
||||
- The pin in ALIGNMENT.md is conservative by design — it names the version OLP
|
||||
was authored against, not the highest version known to work. Re-pinning to
|
||||
v2.1.132 (or whichever version is current) is the right action at the next
|
||||
Anthropic-plugin touch, when a reviewer can confirm no regressions.
|
||||
- Re-audit recommended at: (a) next material change to
|
||||
`lib/providers/anthropic.mjs`, OR (b) 14 May 2027 Annual Alignment Audit,
|
||||
OR (c) the post-2026-06-15 one-shot triggered audit (ALIGNMENT.md
|
||||
§ One-shot Triggered Audits) — whichever comes first.
|
||||
|
||||
## Sample invocation
|
||||
|
||||
The Anthropic plugin spawns the CLI with this argument shape (see
|
||||
`lib/providers/anthropic.mjs` § `buildClaudeArgs` and `_spawnAndStream`):
|
||||
|
||||
```
|
||||
claude -p --output-format text --no-session-persistence --model <model> [--debug]
|
||||
```
|
||||
|
||||
- `-p` puts the CLI in non-interactive (print-and-exit) mode.
|
||||
- `--output-format text` selects plain-text stdout. The plugin parses stdout as
|
||||
plain text (no NDJSON envelope).
|
||||
- `--no-session-persistence` disables session storage so OLP remains stateless
|
||||
(per ADR 0001 § Non-mission — OLP is not a conversation-state store).
|
||||
- `--model <model>` is forwarded from the IR's `model` field.
|
||||
- `--debug` is added only when `OLP_DEBUG_CLAUDE` env is set, for development.
|
||||
|
||||
The prompt is written to the CLI's stdin (`messagesToPrompt(ir.messages)` from
|
||||
`anthropic.mjs`), not passed as a positional argument.
|
||||
|
||||
## Flag surface captured today
|
||||
|
||||
Excerpt from `claude -p --help` on host home-mac on 2026-05-24 (v2.1.132). Only
|
||||
the flags relevant to OLP's invocation are reproduced; the full help is much
|
||||
larger.
|
||||
|
||||
| Flag | Description (verbatim, abridged) |
|
||||
|---|---|
|
||||
| `-p, --print` | Print response and exit (useful for pipes). |
|
||||
| `--output-format <format>` | Output format (only works with --print): "text" (default), "json" (single result), or "stream-json" (realtime streaming) — choices: "text", "json", "stream-json". |
|
||||
| `--no-session-persistence` | Disable session persistence — sessions will not be saved to disk and cannot be resumed (only works with --print). |
|
||||
| `--model <model>` | Model for the current session. Provide an alias for the latest model (e.g. 'sonnet' or 'opus') or a model's full name (e.g. 'claude-sonnet-4-6'). |
|
||||
| `-d, --debug [filter]` | Enable debug mode with optional category filtering (e.g., "api,hooks" or "!1p,!file"). |
|
||||
|
||||
All four load-bearing flags (`-p`, `--output-format`, `--no-session-persistence`,
|
||||
`--model`) are present and accept the same value formats the OLP plugin uses.
|
||||
The `--debug` flag is also present with the same semantics.
|
||||
|
||||
## Citation cross-references
|
||||
|
||||
- ALIGNMENT.md § Authorities § "Provider authority pins" anthropic row — names
|
||||
this file as the transcript-artifact pin.
|
||||
- `lib/providers/anthropic.mjs` header lines 1-50 — names this file as the
|
||||
version-capture artifact (D26 F18 follow-up).
|
||||
- ADR 0001 § Mission inheritance — establishes `claude -p` as Authority 1
|
||||
source for the `anthropic` plugin.
|
||||
- ADR 0005 Amendment 5 (D31 F11) — clarifies the wire limitation of
|
||||
`claude -p --output-format text` w.r.t. cache_control marker delegation.
|
||||
|
||||
## Recapture procedure
|
||||
|
||||
When this artifact is next refreshed (per the "Version drift note" trigger
|
||||
list), the maintainer should:
|
||||
|
||||
1. Run `claude --version` on the project maintainer's primary workstation.
|
||||
2. Run `claude -p --help` and verify that `-p`, `--output-format`,
|
||||
`--no-session-persistence`, `--model`, and `--debug` are all still present
|
||||
with the same value formats.
|
||||
3. Update the "Last capture", "Observed `claude --version`", and "Flag surface
|
||||
captured today" sections with the new values.
|
||||
4. If any flag's semantic has changed (not just a version bump), file an ADR
|
||||
amendment on `lib/providers/anthropic.mjs` Authority 1 source and pin the
|
||||
new version in ALIGNMENT.md.
|
||||
5. If `claude -p --help` no longer enumerates one of OLP's load-bearing flags,
|
||||
the plugin is broken against the new CLI — file a deletion or migration PR
|
||||
per ALIGNMENT.md Rule 4 (Unalignable Plugins / Fields Are Deleted).
|
||||
@@ -0,0 +1,105 @@
|
||||
# OLP v1.x Roadmap — Deferred Work Tracker
|
||||
|
||||
**Purpose.** Single landing page for every Phase-1 deferral that an actual v1.x sprint must pick up. Each entry cross-references its ratifying ADR, its GitHub issue (if any), and the load-bearing code anchor so a future maintainer can resume without spelunking the commit history.
|
||||
|
||||
**Status:** Living document. Add new entries at the top. Each item should answer:
|
||||
1. **What** is deferred?
|
||||
2. **Why** was it deferred (link the ratifying ADR amendment).
|
||||
3. **Where** does the work live in the tree today (file + anchor).
|
||||
4. **When** does it need to land (trigger: load profile, security event, governance amendment).
|
||||
|
||||
**Reading order for a v1.x sprint kickoff.** Items #1–#3 are the most architecturally consequential and should be designed in dependency order: #2 (multi-key auth) blocks header gating in #1 and observability ownership in #4. #1 (streaming SF) blocks #5 (soft trigger reactivation) only if soft triggers are wired on streaming requests.
|
||||
|
||||
---
|
||||
|
||||
## #1 — Streaming-path singleflight + TOCTOU close
|
||||
|
||||
- **What.** `cacheStore.getOrComputeStreaming(keyId, cacheKey, sourceFactory)` API replacing the current `peek + spawn` pattern in `server.mjs`. Per-(keyId, cacheKey) inflight Map with tee fan-out, bounded per-client backpressure queues, late-joiner replay buffer, AbortController propagation on all-disconnect.
|
||||
- **Why deferred.** Personal/family-scale single-tenant load — N concurrent identical streaming requests is an edge case that has not been reported. Each concurrent caller receives the correct response; the waste is N CLI processes instead of one.
|
||||
- **Design ADR (ratified).** [`docs/adr/0005-cache-cross-provider.md` Amendment 8](./adr/0005-cache-cross-provider.md) — full design including the inflight Map shape, tee policy, late-joiner replay, backpressure cap, D38 semaphore coordination, abort policy, cache TTL race handling, observability event set, and X-OLP-Streaming-Inflight header. Implementation acceptance criteria are in Amendment 8 §13.
|
||||
- **Tracking issue.** GitHub issue [#16](https://github.com/dtzp555-max/olp/issues/16) — STAYS OPEN as v1.x tracker. Sibling: the closed-but-not-implemented Amendment 6 deferral (D34 F1).
|
||||
- **Code anchors today.**
|
||||
- `server.mjs` lines ~782 (`preCheckHit = await cacheStore.peek(...)`) and ~811–817 (streaming branch entry) — these are the lines the new API replaces.
|
||||
- `lib/cache/store.mjs` `getOrCompute` — sibling API; the new one mirrors its shape on the streaming path.
|
||||
- **Trigger to start.** Any of: (a) report of N>1 concurrent identical streaming requests in the wild, (b) v1.x sprint planning kickoff with the maintainer explicitly opening this scope, (c) downstream feature requiring tee-streaming primitive (e.g., browser-side observer attaching to an existing stream).
|
||||
- **Estimated effort.** Design ADR ratified (Amendment 8) = 30 min done. Implementation = 200–400 lines + 15-20 tests + fresh-context reviewer pass. ~3-4 hours of subagent runtime with full Iron Rule 10 discipline.
|
||||
|
||||
## #2 — Multi-key auth (`lib/keys.mjs`)
|
||||
|
||||
- **What.** Per-API-key identity, namespace scoping for the cache, ownership tier (owner vs guest) for header gating, and audit log of which key issued which request.
|
||||
- **Why deferred.** Phase 1 ships single-tenant — the cache layer carries `keyId='__anonymous__'` (D5). No real user identity is needed for personal/family use today.
|
||||
- **Design ADR (NOT YET RATIFIED).** No design ADR exists yet. v1.x sprint must produce one before implementation.
|
||||
- **Tracking.** Not a GitHub issue (no governance event filed for it). Tracked here + in `AGENTS.md § Key files to know` (`lib/keys.mjs` marked 📋 Planned).
|
||||
- **Blocks.**
|
||||
- `X-OLP-Fallback-Detail` owner-only gating (D40 / ADR 0004 Amendment 5 — currently ungated).
|
||||
- `/health` per-key visibility (currently anonymous-only).
|
||||
- **Code anchors today.**
|
||||
- `lib/cache/store.mjs` per-keyId namespace Map — the data model is already keyed by `keyId`; only the keyId source is hardcoded.
|
||||
- `server.mjs` request handlers — the `keyId='__anonymous__'` constant needs to be replaced by a header/token lookup.
|
||||
- **Trigger to start.** First multi-user deployment of OLP (e.g., maintainer + spouse + child accessing the same instance with separate identities).
|
||||
|
||||
## #3 — Soft trigger reactivation (ADR 0004 Amendment 2)
|
||||
|
||||
- **What.** Per-provider `quotaStatus` polling, `softThreshold` comparisons, soft-skip advancement when quota approaches limit. Currently `evaluateSoftTriggers` always returns `false` because `quotaSnapshot` is never populated.
|
||||
- **Why deferred.** v0.1 hard triggers (SPAWN_FAILED / CLI_NOT_FOUND / SPAWN_TIMEOUT / CONCURRENCY_LIMIT) are sufficient for fallback advancement at personal/family scale. Soft triggers require persistent quota snapshots and a polling mechanism, which adds operational surface (timer drift, snapshot staleness, observability burden).
|
||||
- **Design ADR.** [`docs/adr/0004-fallback-engine.md` Amendment 2](./adr/0004-fallback-engine.md) — explicit v1.x deferral with mitigations (startup warning if user configures soft thresholds without runtime enforcement).
|
||||
- **Tracking.** Not a GitHub issue. Tracked here + via the startup warning in `server.mjs` (the `_softTriggersConfigured` warn emission).
|
||||
- **Blocks.**
|
||||
- Issue #8 (`X-OLP-Provider-Used` chain-origin semantics) — Option A (track `firstAttemptedProvider`) becomes preferable once soft triggers can fire. See ADR 0004 Amendment 6 § v1.x re-evaluation.
|
||||
- `X-OLP-Fallback-Detail` `trigger_type: 'soft'` path — currently dead code, becomes live with this work.
|
||||
- **Code anchors today.**
|
||||
- `lib/fallback/engine.mjs` `evaluateSoftTriggers` (returns false unconditionally at v0.1).
|
||||
- `lib/providers/base.mjs` `Provider.quotaStatus` contract (declared but unused at v0.1).
|
||||
- **Trigger to start.** First quota-rate-limit event in the wild — at which point the operator would want pre-emptive advancement rather than spawn-then-fail.
|
||||
|
||||
## #4 — `/health` `activeSpawns` integration
|
||||
|
||||
- **What.** Surface D38 `getActiveSpawnCount(providerName)` per-provider on the `/health` endpoint at the path `providers.status.<name>.activeSpawns`.
|
||||
- **Why deferred.** D38 (issue #1) shipped the runtime enforcement and exported `getActiveSpawnCount`; `/health` integration was scoped out as forward-looking polish.
|
||||
- **Design ADR.** [`docs/adr/0002-plugin-architecture.md` Amendment 6](./adr/0002-plugin-architecture.md) — names the target path explicitly: "`/health` integration deferred — when surfaced there will land at `providers.status.<name>.activeSpawns`; not wired at D38."
|
||||
- **Tracking.** Not a GitHub issue. Tracked here.
|
||||
- **Code anchors today.**
|
||||
- `lib/providers/index.mjs` exports `getActiveSpawnCount` already.
|
||||
- `server.mjs handleHealth` — extension point for the new field.
|
||||
- **Trigger to start.** First time the maintainer wants per-provider concurrency visibility for capacity planning.
|
||||
|
||||
## #5 — Provider-level `cacheKeyFields` (per-plugin mask)
|
||||
|
||||
- **What.** Per-plugin declaration of which IR fields are actually consumed by the underlying CLI invocation, used by `computeCacheKey` to skip fields that the plugin drops at spawn. Reduces spurious-miss rate from the v0.1 conservative-posture trade-off (Amendment 7).
|
||||
- **Why deferred.** At personal/family scale the extra spawn cost from spurious misses is negligible. The contract extension adds complexity (per-plugin field set + plumbing through `buildDefaultChain` → `executeHopFn` → `computeCacheKey`).
|
||||
- **Design ADR.** [`docs/adr/0005-cache-cross-provider.md` Amendment 7 § Forward path](./adr/0005-cache-cross-provider.md).
|
||||
- **Tracking.** Not a GitHub issue. Tracked here.
|
||||
- **Code anchors today.**
|
||||
- Plugin file headers — each lists its "fields dropped at spawn" table for human reference; the v1.x amendment makes that table machine-readable.
|
||||
- `lib/cache/keys.mjs computeCacheKey` — would accept `pluginCacheKeyMask` parameter.
|
||||
- **Trigger to start.** First time spurious-miss rate becomes a measurable load factor.
|
||||
|
||||
## #6 — Streaming-path SPAWN_FAILED salvage
|
||||
|
||||
- **What.** Currently the streaming branch does NOT participate in D16 salvage (the salvage-on-SPAWN_FAILED + chunks pattern that the buffered path uses). Streaming SPAWN_FAILED mid-stream → the truncation marker (D35 #10) fires, but no salvage logic captures partial chunks for downstream cache reuse.
|
||||
- **Why deferred.** Less impactful than #1 — at most one client benefits per spawn event, and the buffered path already provides salvage for the bulk of requests. Streaming is the minority path.
|
||||
- **Design ADR.** Not yet ratified. Coordinated with #1 because the tee architecture changes the salvage semantics (multiple clients may want different finish_reason interpretations on source-mid-stream-failure).
|
||||
- **Tracking.** Not a GitHub issue. Tracked here.
|
||||
- **Trigger to start.** Bundled with #1 implementation work (the inflight tee architecture changes the salvage semantics, so designing them together is cheaper than serializing).
|
||||
|
||||
## #7 — AUTH_MISSING tuple path test coverage (D40 follow-up)
|
||||
|
||||
- **What.** Dedicated test in `test-features.mjs` Suite D40 that asserts the `fallbackDetail` tuple records the AUTH_MISSING path with `trigger_type: 'auth_missing'`. D40 reviewer flagged this as the last gap in the engine-path matrix; code is structurally correct, just lacks an explicit pin.
|
||||
- **Why deferred.** Low priority — the AUTH_MISSING early-return branch has the tuple push BEFORE it (verified in D40 reviewer pass), so coverage is implicit via the other engine-path tests. A 3-line dedicated test would make the pin explicit.
|
||||
- **Design.** No ADR needed. ~5-line test addition.
|
||||
- **Tracking.** Not a GitHub issue. Tracked here.
|
||||
- **Trigger to start.** Next routine test-suite hardening pass, OR when AUTH_MISSING handling is changed for any reason.
|
||||
|
||||
---
|
||||
|
||||
## Adding a new entry
|
||||
|
||||
When a future D-day defers work, the deferring commit should:
|
||||
|
||||
1. **Always** update this file with a new entry at the top.
|
||||
2. **Always** name the ratifying ADR amendment (or note "no ADR yet — future work needs one").
|
||||
3. **Always** name the load-bearing code anchor (`file:line` form preferred over symbolic names — the symbolic name can drift).
|
||||
4. **Always** name a concrete trigger to start the work — vague triggers ("when needed") let entries rot.
|
||||
5. If the deferral has a GitHub issue, keep it OPEN and reference it here. If it does NOT, leave a note explaining why (e.g., "tracked here only — no external governance event filed").
|
||||
|
||||
The maintainer's session-startup discipline should grep this file at sprint kickoff. If an entry's "trigger to start" condition is met, it leaves this page and becomes a sprint item.
|
||||
Vendored
+41
@@ -265,6 +265,14 @@ export class CacheStore {
|
||||
* @param {() => Promise<*>} computeFn - async function producing the value
|
||||
* @param {number} [ttlMs]
|
||||
* @returns {Promise<*>}
|
||||
*
|
||||
* TODO(v1.x — ADR 0005 Amendment 8 / issue #16): add a sibling
|
||||
* `getOrComputeStreaming(keyId, cacheKey, sourceFactory)` for the streaming
|
||||
* path. This API handles buffered responses only; the streaming branch in
|
||||
* server.mjs currently uses a peek+spawn pattern with a TOCTOU window.
|
||||
* The streaming sibling will mirror this method's shape but with a tee
|
||||
* fan-out and per-client backpressure queues. See docs/v1x-roadmap.md #1
|
||||
* for the design contract and acceptance criteria.
|
||||
*/
|
||||
async getOrCompute(keyId, cacheKey, computeFn, ttlMs) {
|
||||
// 1. Cache hit — return immediately, no singleflight overhead
|
||||
@@ -341,6 +349,39 @@ export class CacheStore {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a specific (keyId, cacheKey) entry immediately.
|
||||
*
|
||||
* ADR 0005 § "Cache write conditions" item 1 (D39, issue #3 Part 1):
|
||||
* D16 truncation-eviction previously used `set(..., ttlMs=0)` to leave a
|
||||
* tombstone that the next `get`/`peek` would lazily purge. That pattern
|
||||
* left dead entries in the namespace Map until next access, accruing
|
||||
* memory if no follow-up read ever fires. `delete(keyId, cacheKey)` makes
|
||||
* the eviction explicit and immediate.
|
||||
*
|
||||
* Memory hygiene: if the per-keyId namespace becomes empty after delete,
|
||||
* the namespace Map entry itself is removed (mirrors the pattern in D38
|
||||
* `_activeSpawns` so empty namespaces don't accumulate in `_store`).
|
||||
*
|
||||
* Stats: this method does NOT touch hit/miss counters — it is an eviction
|
||||
* primitive, not a read. Aggregate `size` reported by `stats()` reflects
|
||||
* the removal on the next call.
|
||||
*
|
||||
* @param {string} keyId
|
||||
* @param {string} cacheKey
|
||||
* @returns {boolean} true if the entry was present and removed; false if absent.
|
||||
*/
|
||||
delete(keyId, cacheKey) {
|
||||
const ns = this._store.get(keyId);
|
||||
if (!ns) return false;
|
||||
const had = ns.delete(cacheKey);
|
||||
// Memory hygiene: drop empty namespace Map entries.
|
||||
if (had && ns.size === 0) {
|
||||
this._store.delete(keyId);
|
||||
}
|
||||
return had;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears cache entries (and stats) for a specific keyId, or ALL entries.
|
||||
*
|
||||
|
||||
+120
-14
@@ -27,11 +27,16 @@ import { computeIRRequestHash } from '../cache/keys.mjs';
|
||||
/**
|
||||
* Maps ProviderError codes to hard-trigger decisions.
|
||||
*
|
||||
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7):
|
||||
* - SPAWN_FAILED → hard trigger (provider CLI failed)
|
||||
* - CLI_NOT_FOUND → hard trigger (binary missing)
|
||||
* - AUTH_MISSING → NOT a hard trigger (user-config failure; user must fix)
|
||||
* - SPAWN_TIMEOUT → hard trigger (per ADR 0004 § Trigger taxonomy bullet 4)
|
||||
* v0.1 live codes (per ADR 0004 Amendment 3 (D34 F7) + Amendment 4 (D38)):
|
||||
* - SPAWN_FAILED → hard trigger (provider CLI failed)
|
||||
* - CLI_NOT_FOUND → hard trigger (binary missing)
|
||||
* - AUTH_MISSING → NOT a hard trigger (user-config failure; user must fix)
|
||||
* - SPAWN_TIMEOUT → hard trigger (per ADR 0004 § Trigger taxonomy bullet 4)
|
||||
* - CONCURRENCY_LIMIT → hard trigger (D38 / issue #1, ADR 0004 Amendment 4):
|
||||
* synthesized by server.mjs when a provider is at its
|
||||
* hints.maxConcurrent in-flight limit. The chain
|
||||
* advances immediately to the next hop instead of
|
||||
* queueing — design rationale per ADR 0004 Amendment 4.
|
||||
*
|
||||
* QUOTA_EXHAUSTED and RATE_LIMITED removed (D34 F7 / ADR 0004 Amendment 3):
|
||||
* no v0.1 plugin parses underlying-API HTTP status codes, so these codes
|
||||
@@ -44,8 +49,9 @@ import { computeIRRequestHash } from '../cache/keys.mjs';
|
||||
const HARD_TRIGGER_CODES = {
|
||||
SPAWN_FAILED: true,
|
||||
CLI_NOT_FOUND: true,
|
||||
AUTH_MISSING: false, // user config problem — never fall over (ADR 0004 § Decision)
|
||||
SPAWN_TIMEOUT: true, // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
AUTH_MISSING: false, // user config problem — never fall over (ADR 0004 § Decision)
|
||||
SPAWN_TIMEOUT: true, // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
CONCURRENCY_LIMIT: true, // ADR 0004 Amendment 4 (D38, issue #1): saturation → advance chain
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -221,6 +227,18 @@ export function evaluateSoftTriggers(triggerConfig, quotaSnapshot) {
|
||||
* @property {object|null} [quotaSnapshot] — optional pre-fetched quota snapshot
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FallbackDetailTuple — per-hop failure detail tuple emitted in X-OLP-Fallback-Detail.
|
||||
* D40 (issue #7) — Option A (ungated v0.1). Shapes reuse D28 log event fields so future readers
|
||||
* can grep both surfaces consistently.
|
||||
* @property {number} hop — 0-indexed hop number
|
||||
* @property {string} provider — provider name at this hop
|
||||
* @property {string} model — model string at this hop (from chain hop, which carries IR model)
|
||||
* @property {string} code — ProviderError code, or 'UNKNOWN' for non-ProviderError exceptions
|
||||
* @property {string} error_message — error message, truncated to 200 chars
|
||||
* @property {string} trigger_type — classifyTrigger() output: 'hard' | 'auth_missing' | 'client_error' | 'non_trigger' | 'soft' for engine-synthesized soft skips
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} FallbackResult
|
||||
* @property {Array<object>|null} chunks — IR chunk array on success; null if exhausted
|
||||
@@ -229,8 +247,65 @@ export function evaluateSoftTriggers(triggerConfig, quotaSnapshot) {
|
||||
* @property {number} fallbackHops — chain index of the serving hop (0=primary, 1=first fallback, etc.)
|
||||
* @property {Error|null} originalError — first-hop error if exhausted; null on success
|
||||
* @property {string[]} triedProviders — all providers tried, in chain order
|
||||
* @property {FallbackDetailTuple[]} fallbackDetail — per-hop failure tuples (D40, issue #7).
|
||||
* On success, contains the failing hops before the serving hop (may be empty).
|
||||
* On exhausted/non-trigger/client-error/auth-missing return paths, contains every failed hop.
|
||||
* Server.mjs emits X-OLP-Fallback-Detail when this array is non-empty.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Truncates an error message to at most 200 characters. If truncation occurs,
|
||||
* the result ends with a single-character ellipsis (U+2026 '…') to signal
|
||||
* the cut. Used to keep X-OLP-Fallback-Detail tuples readable in dashboards.
|
||||
*
|
||||
* D40 (issue #7) — see ADR 0004 § Observability headers.
|
||||
*
|
||||
* @param {unknown} message
|
||||
* @returns {string}
|
||||
*/
|
||||
function truncateErrorMessage(message) {
|
||||
const s = typeof message === 'string' ? message : String(message ?? '');
|
||||
if (s.length <= 200) return s;
|
||||
return s.slice(0, 199) + '…';
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the per-hop failure tuple emitted in X-OLP-Fallback-Detail.
|
||||
* Field shapes reuse D28's structured log event values so the header and
|
||||
* the log line are pivotable on the same keys.
|
||||
*
|
||||
* D40 (issue #7) — see ADR 0004 § Observability headers.
|
||||
*
|
||||
* @param {number} hop
|
||||
* @param {string} provider
|
||||
* @param {string} model
|
||||
* @param {Error} err
|
||||
* @param {'hard'|'soft'|'auth_missing'|'client_error'|'non_trigger'|null} triggerType
|
||||
* @returns {FallbackDetailTuple}
|
||||
*/
|
||||
function makeFallbackDetailTuple(hop, provider, model, err, triggerType) {
|
||||
let code;
|
||||
if (err instanceof ProviderError && err.code) {
|
||||
code = err.code;
|
||||
} else if (typeof err?.code === 'string') {
|
||||
// Carries err.code from soft-trigger synthesized errors (code: 'SOFT_TRIGGER')
|
||||
// or any custom error class that uses string codes. Non-string err.code
|
||||
// falls through to 'UNKNOWN' so a numeric Node errno (e.g. ECONNREFUSED's
|
||||
// numeric system errno) does not get mis-typed.
|
||||
code = err.code;
|
||||
} else {
|
||||
code = 'UNKNOWN';
|
||||
}
|
||||
return {
|
||||
hop,
|
||||
provider,
|
||||
model,
|
||||
code,
|
||||
error_message: truncateErrorMessage(err?.message),
|
||||
trigger_type: triggerType ?? 'non_trigger',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes a provider chain with fallback semantics.
|
||||
*
|
||||
@@ -270,6 +345,12 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
let originalError = null; // Per ADR 0004: first-hop error is the canonical signal
|
||||
let firstErrorRecorded = false;
|
||||
|
||||
// D40 (issue #7) — per-hop failure tuples for X-OLP-Fallback-Detail.
|
||||
// Reuses D28 log event field shapes; emitted by server.mjs on any response
|
||||
// where this array is non-empty. ADR 0004 § Observability headers.
|
||||
/** @type {FallbackDetailTuple[]} */
|
||||
const fallbackDetail = [];
|
||||
|
||||
for (let i = 0; i < chain.length; i++) {
|
||||
const hop = chain[i];
|
||||
const { provider, model, softTriggers = null, quotaSnapshot = null } = hop;
|
||||
@@ -307,13 +388,17 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
// should treat 'SOFT_TRIGGER' as engine-synthetic. D9 review-2 noted
|
||||
// this; documented here so future readers do not try to add SOFT_TRIGGER
|
||||
// to the PROVIDER_ERROR_CODES closed enum.
|
||||
const softErr = Object.assign(
|
||||
new Error(`Soft trigger fired for provider ${provider}: quota threshold exceeded`),
|
||||
{ code: 'SOFT_TRIGGER', provider },
|
||||
);
|
||||
if (!firstErrorRecorded) {
|
||||
originalError = Object.assign(
|
||||
new Error(`Soft trigger fired for provider ${provider}: quota threshold exceeded`),
|
||||
{ code: 'SOFT_TRIGGER', provider },
|
||||
);
|
||||
originalError = softErr;
|
||||
firstErrorRecorded = true;
|
||||
}
|
||||
// D40: record soft-skipped hop in fallbackDetail. trigger_type='soft' lets
|
||||
// downstream readers distinguish a skipped hop from a spawned-and-failed one.
|
||||
fallbackDetail.push(makeFallbackDetailTuple(i, provider, model, softErr, 'soft'));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -342,6 +427,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
fallbackHops: i,
|
||||
originalError: null,
|
||||
triedProviders,
|
||||
fallbackDetail, // D40: failing hops that came BEFORE this success
|
||||
};
|
||||
} catch (err) {
|
||||
// Record FIRST hop error as the canonical signal
|
||||
@@ -351,6 +437,12 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
firstErrorRecorded = true;
|
||||
}
|
||||
|
||||
// D40 (issue #7): classify once and record the per-hop tuple. The same
|
||||
// trigger_type value flows into the log event below (consistency between
|
||||
// logs and X-OLP-Fallback-Detail).
|
||||
const errTriggerType = classifyTrigger(err);
|
||||
fallbackDetail.push(makeFallbackDetailTuple(i, provider, model, err, errTriggerType));
|
||||
|
||||
logEvent('warn', 'fallback_hop_error', {
|
||||
chain_id: chainId,
|
||||
hop: i,
|
||||
@@ -358,7 +450,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
model,
|
||||
error: err.message,
|
||||
code: err.code ?? null,
|
||||
trigger_type: classifyTrigger(err),
|
||||
trigger_type: errTriggerType,
|
||||
ir_request_hash: irRequestHash,
|
||||
next_provider: chain[i + 1]?.provider ?? null,
|
||||
});
|
||||
@@ -383,6 +475,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
fallbackHops: i,
|
||||
originalError: err,
|
||||
triedProviders,
|
||||
fallbackDetail, // D40
|
||||
};
|
||||
}
|
||||
|
||||
@@ -406,6 +499,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
fallbackHops: i,
|
||||
originalError: err,
|
||||
triedProviders,
|
||||
fallbackDetail, // D40
|
||||
};
|
||||
}
|
||||
|
||||
@@ -422,7 +516,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
error: err.message,
|
||||
code: err.code ?? null,
|
||||
advance_to_hop: i + 1,
|
||||
trigger_type: classifyTrigger(err),
|
||||
trigger_type: errTriggerType,
|
||||
ir_request_hash: irRequestHash,
|
||||
next_provider: chain[i + 1]?.provider ?? null,
|
||||
});
|
||||
@@ -437,7 +531,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
provider,
|
||||
error: err.message,
|
||||
code: err.code ?? null,
|
||||
trigger_type: classifyTrigger(err),
|
||||
trigger_type: errTriggerType,
|
||||
ir_request_hash: irRequestHash,
|
||||
next_provider: null,
|
||||
});
|
||||
@@ -448,6 +542,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
fallbackHops: i,
|
||||
originalError: err,
|
||||
triedProviders,
|
||||
fallbackDetail, // D40
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -464,6 +559,16 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
next_provider: null,
|
||||
});
|
||||
|
||||
// D41 (issue #8): providerUsed on chain-exhausted reflects **chain origin**
|
||||
// (the configured primary), not necessarily the first hop where spawn() was
|
||||
// actually called. At v0.1 the two are equivalent because soft triggers are
|
||||
// deferred (ADR 0004 Amendment 2) — every hop in the chain is attempted in
|
||||
// order. When soft triggers are reactivated in v1.x, the semantic ambiguity
|
||||
// surfaces: a soft-skipped hop 0 followed by hard-failed hops 1+N would
|
||||
// report providerUsed=chain[0] even though hop 0 was never spawned. The v0.1
|
||||
// contract is chain-origin (option b); v1.x may switch to first-attempted-
|
||||
// hop (option a) as part of the soft-trigger reactivation work. See ADR 0004
|
||||
// Amendment 6 for the documented semantics.
|
||||
return {
|
||||
chunks: null,
|
||||
providerUsed: chain[0].provider,
|
||||
@@ -471,6 +576,7 @@ export async function executeWithFallback(chain, irRequest, executeHopFn, option
|
||||
fallbackHops: chain.length,
|
||||
originalError,
|
||||
triedProviders,
|
||||
fallbackDetail, // D40 (issue #7): per-hop failure tuples; every attempted hop on exhaustion
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+10
-1
@@ -40,7 +40,7 @@ export const VALID_ROLES = ['system', 'user', 'assistant', 'tool'];
|
||||
|
||||
/**
|
||||
* @typedef {Object} IRRequest
|
||||
* @property {string} irVersion - always IR_VERSION
|
||||
* @property {string} [irVersion] - optional; when present must equal IR_VERSION ('1.0'). Pre-D35 IRs lack this field and remain valid.
|
||||
* @property {IRMessage[]} messages
|
||||
* @property {string} model
|
||||
* @property {boolean} stream
|
||||
@@ -177,6 +177,15 @@ export function validateIRRequest(obj) {
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: irVersion — must be '1.0' if present; undefined accepted for pre-existing IRs
|
||||
// Per ADR 0003 § Required fields: analogous to contractVersion='1.0' in base.mjs.
|
||||
// Decision: undefined accepted because openai-to-ir.mjs sets irVersion on construction;
|
||||
// pre-existing IRs without it still validate. Strict '1.0' rejection only when explicitly
|
||||
// set wrong.
|
||||
if (obj.irVersion !== undefined && obj.irVersion !== '1.0') {
|
||||
errors.push(`irVersion must be '1.0' (got: ${JSON.stringify(obj.irVersion)})`);
|
||||
}
|
||||
|
||||
// Optional: tool_choice — 'auto' | 'none' | 'required' | {type:'function', function:{name}}
|
||||
if (obj.tool_choice !== undefined) {
|
||||
if (typeof obj.tool_choice === 'string') {
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
* present at D4 implementation (captured at D4 implementation per ALIGNMENT.md
|
||||
* Rule 5 — the circular ALIGNMENT.md ↔ plugin header citation is resolved by
|
||||
* this in-plugin record of the OLP-side observation).
|
||||
* D36 #15: See docs/provider-audits/anthropic.md for the version-capture
|
||||
* artifact (single living document — captured 2026-05-24; re-capture at every
|
||||
* plugin touch or annual audit). The artifact records the live `claude --version`
|
||||
* today (v2.1.132) versus the plugin pin (v2.1.89, D4) and verifies that the
|
||||
* load-bearing flags (-p, --output-format, --no-session-persistence, --model,
|
||||
* --debug) are all still present and semantically unchanged in the current binary.
|
||||
*
|
||||
* Spawn pattern ported from:
|
||||
* OCP server.mjs:384-414 (buildCliArgs — -p / --model / --output-format / --no-session-persistence)
|
||||
@@ -360,7 +366,8 @@ async function* _spawnAndStream(irRequest, authContext, spawnImpl) {
|
||||
// timeout. This unconditional throw closes the race — SPAWN_TIMEOUT always
|
||||
// surfaces as a hard trigger to the fallback engine regardless of which path
|
||||
// the timer fire took. Note: any partial chunks already yielded are discarded
|
||||
// by the caller; SPAWN_TIMEOUT salvage parity is tracked in issue #3.
|
||||
// by the caller. SPAWN_TIMEOUT is intentionally excluded from D16 salvage —
|
||||
// see ADR 0004 Amendment 1 § "Why SPAWN_TIMEOUT is excluded from salvage".
|
||||
if (spawnTimedOut) {
|
||||
throw new ProviderError(
|
||||
`claude spawn timed out after ${maxSpawnTimeMs}ms`,
|
||||
|
||||
+11
-3
@@ -141,8 +141,15 @@ export function validateProvider(p) {
|
||||
/**
|
||||
* Error codes surfaced by provider plugins.
|
||||
*
|
||||
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7):
|
||||
* SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING, SPAWN_TIMEOUT
|
||||
* v0.1 live codes (per ADR 0004 Amendment 3, D34 F7 and Amendment 4, D38):
|
||||
* SPAWN_FAILED, CLI_NOT_FOUND, AUTH_MISSING, SPAWN_TIMEOUT, CONCURRENCY_LIMIT
|
||||
*
|
||||
* CONCURRENCY_LIMIT (D38, issue #1): synthesized by the orchestration layer
|
||||
* (NOT thrown by provider plugins themselves) when a spawn is attempted
|
||||
* against a provider already at its hints.maxConcurrent limit. The fallback
|
||||
* engine treats this as a hard trigger so the chain advances to the next hop
|
||||
* rather than queueing. See ADR 0002 Amendment 6 (runtime enforcement) and
|
||||
* ADR 0004 Amendment 4 (CONCURRENCY_LIMIT in hard-trigger taxonomy).
|
||||
*
|
||||
* QUOTA_EXHAUSTED and RATE_LIMITED were removed (D34 F7): no plugin parses
|
||||
* underlying-API HTTP status codes at v0.1, so these codes are never emitted.
|
||||
@@ -153,7 +160,8 @@ export const PROVIDER_ERROR_CODES = /** @type {const} */ ([
|
||||
'AUTH_MISSING',
|
||||
'CLI_NOT_FOUND',
|
||||
'SPAWN_FAILED',
|
||||
'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
'SPAWN_TIMEOUT', // ADR 0004 § Trigger taxonomy bullet 4: spawn timeout is a hard trigger
|
||||
'CONCURRENCY_LIMIT', // ADR 0002 Amendment 6 / ADR 0004 Amendment 4 (D38, issue #1)
|
||||
]);
|
||||
|
||||
export class ProviderError extends Error {
|
||||
|
||||
@@ -209,3 +209,133 @@ export function getProviderByName(loadedProviders, name) {
|
||||
export function listAllProviderNames() {
|
||||
return STATIC_REGISTRY.map(p => p.name);
|
||||
}
|
||||
|
||||
// ── Concurrency semaphore (D38, issue #1) ─────────────────────────────────
|
||||
//
|
||||
// Authority: ADR 0002 Amendment 6 (maxConcurrent runtime enforcement landed in D38)
|
||||
// and ADR 0004 Amendment 4 (CONCURRENCY_LIMIT added to hard-trigger taxonomy).
|
||||
//
|
||||
// Per-provider in-flight spawn counter. The orchestration layer (server.mjs
|
||||
// handleChatCompletions) calls tryAcquireSpawn() before provider.spawn() and
|
||||
// releaseSpawn() after spawn lifecycle completion. On saturation, the caller
|
||||
// synthesises a ProviderError(CONCURRENCY_LIMIT) which the fallback engine
|
||||
// treats as a hard trigger — the chain advances to the next hop. If the
|
||||
// entire chain is saturated, the user receives a chain-exhausted error via
|
||||
// the existing executeWithFallback exhaustion path.
|
||||
//
|
||||
// Design decision (deliberate): immediate-advancement via fallback, NOT
|
||||
// queue+timeout. Rationale (per D38 issue #1 design discussion):
|
||||
// 1. The fallback chain exists precisely for this kind of overflow.
|
||||
// 2. A queue introduces head-of-line blocking + a new timeout config surface.
|
||||
// 3. Immediate-advancement gives fail-fast latency, matching the OLP
|
||||
// multi-provider proxy philosophy.
|
||||
// 4. Queue+timeout is deferred — track via a future issue if real usage
|
||||
// shows need.
|
||||
//
|
||||
// **Atomicity invariant**: JavaScript is single-threaded; the
|
||||
// read-then-write pair inside tryAcquireSpawn() executes synchronously with
|
||||
// NO `await` between the check and the increment. This is the only reason
|
||||
// the semaphore is correct without a Mutex. A future async refactor MUST
|
||||
// preserve this — do NOT introduce an `await` between the limit check and
|
||||
// the count update or the semaphore loses its mutual-exclusion guarantee
|
||||
// (two callers could each read count=limit-1 before either increments).
|
||||
//
|
||||
// Module-level state: lives for the process lifetime; tests that need
|
||||
// isolation should call __resetSpawnCounters() in their teardown.
|
||||
//
|
||||
// @type {Map<string, number>} provider name → current in-flight spawn count
|
||||
const _activeSpawns = new Map();
|
||||
|
||||
/**
|
||||
* Default cap for tryAcquireSpawn when a plugin omits hints.maxConcurrent.
|
||||
*
|
||||
* validateProvider in base.mjs requires hints.maxConcurrent to be a
|
||||
* non-negative integer at startup, so a missing value should not happen in
|
||||
* production. This default is defense-in-depth for callers that pass a
|
||||
* stripped-down provider stub (e.g., in tests) or future plugin paths that
|
||||
* bypass validation. The value (4) matches the v0.1 plugin defaults
|
||||
* (anthropic / codex / mistral all declare hints.maxConcurrent: 4).
|
||||
*/
|
||||
export const DEFAULT_MAX_CONCURRENT_SPAWNS = 4;
|
||||
|
||||
/**
|
||||
* Atomically attempts to reserve a spawn slot for `providerName`.
|
||||
*
|
||||
* If the current in-flight count is below `maxConcurrent`, increments the
|
||||
* counter and returns true. Otherwise returns false WITHOUT incrementing —
|
||||
* the caller is responsible for surfacing the saturation as a
|
||||
* ProviderError(CONCURRENCY_LIMIT) for the fallback engine to consume.
|
||||
*
|
||||
* Atomicity: the check and the increment happen in a single synchronous
|
||||
* block with no `await` in between. See the module-level invariant comment
|
||||
* above for why this is sufficient.
|
||||
*
|
||||
* @param {string} providerName — provider key (e.g. 'anthropic')
|
||||
* @param {number} [maxConcurrent=DEFAULT_MAX_CONCURRENT_SPAWNS] — limit from hints.maxConcurrent
|
||||
* @returns {boolean} true if a slot was acquired, false if at limit
|
||||
*/
|
||||
export function tryAcquireSpawn(providerName, maxConcurrent = DEFAULT_MAX_CONCURRENT_SPAWNS) {
|
||||
// Defensive: coerce undefined/null/non-integer to the default. validateProvider
|
||||
// already enforces this at startup; this guards future plugin paths that
|
||||
// bypass validation.
|
||||
const limit = (typeof maxConcurrent === 'number' && Number.isInteger(maxConcurrent) && maxConcurrent >= 0)
|
||||
? maxConcurrent
|
||||
: DEFAULT_MAX_CONCURRENT_SPAWNS;
|
||||
|
||||
const current = _activeSpawns.get(providerName) ?? 0;
|
||||
// Atomic check-then-increment (no `await` between read and write).
|
||||
if (current >= limit) {
|
||||
return false;
|
||||
}
|
||||
_activeSpawns.set(providerName, current + 1);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Releases a spawn slot for `providerName`. Must be called exactly once per
|
||||
* successful tryAcquireSpawn() call, regardless of whether the spawn succeeded
|
||||
* or threw. The caller in server.mjs uses a try/finally pattern to guarantee
|
||||
* the release fires on every exit path (success, error, abort, streaming end).
|
||||
*
|
||||
* Throws if the count would go negative — that indicates a bug (a release
|
||||
* without a matching acquire, or a double-release). The throw is loud on
|
||||
* purpose so the bug surfaces in tests rather than silently corrupting the
|
||||
* counter for future requests.
|
||||
*
|
||||
* @param {string} providerName — provider key (e.g. 'anthropic')
|
||||
* @throws {Error} if no slot is currently held for providerName
|
||||
*/
|
||||
export function releaseSpawn(providerName) {
|
||||
const current = _activeSpawns.get(providerName) ?? 0;
|
||||
if (current <= 0) {
|
||||
throw new Error(
|
||||
`releaseSpawn(${providerName}): counter would go negative — release without matching acquire (or double-release)`,
|
||||
);
|
||||
}
|
||||
const next = current - 1;
|
||||
if (next === 0) {
|
||||
_activeSpawns.delete(providerName);
|
||||
} else {
|
||||
_activeSpawns.set(providerName, next);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current in-flight spawn count for `providerName`. Used by
|
||||
* /health, diagnostics, and tests that need to assert peak concurrency.
|
||||
*
|
||||
* @param {string} providerName
|
||||
* @returns {number} non-negative integer; 0 if no spawns in flight
|
||||
*/
|
||||
export function getActiveSpawnCount(providerName) {
|
||||
return _activeSpawns.get(providerName) ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @internal — test seam: reset all in-flight spawn counters to zero. Used by
|
||||
* test teardown to ensure a clean state across suites. Production code MUST
|
||||
* NOT call this — it bypasses the acquire/release pairing invariant.
|
||||
*/
|
||||
export function __resetSpawnCounters() {
|
||||
_activeSpawns.clear();
|
||||
}
|
||||
|
||||
+15
-10
@@ -142,17 +142,22 @@
|
||||
* D-later E2E will capture real `vibe --output json` stdout and pin the
|
||||
* actual field names; mismatched fields will be corrected then.
|
||||
*
|
||||
* A5 (model flag — UNPINNED-D-later-verifies):
|
||||
* A5 (model flag — CONFIRMED-NOT-APPLICABLE):
|
||||
* Name: model_flag
|
||||
* Status: UNPINNED-D-later-verifies
|
||||
* Basis: DOCS-3 mentions model selection via "/config" inside the interactive
|
||||
* Vibe UI. The quickstart (DOCS-1) does not show a `--model` CLI flag for
|
||||
* programmatic mode. OLP does NOT pass `--model` in the spawn args at D8
|
||||
* because no CLI reference confirms this flag exists on the `vibe` command
|
||||
* (per ALIGNMENT.md Rule 2: "if the underlying authority does not perform
|
||||
* the operation, the PR must state this explicitly").
|
||||
* D-later E2E: run `vibe --help` to enumerate all flags; if --model exists
|
||||
* and the flag name is confirmed, add it to spawn args with the model ID.
|
||||
* Status: CONFIRMED-NOT-APPLICABLE
|
||||
* Basis: DeepWiki (DOCS-4) full CLI command flag enumeration confirms that
|
||||
* `vibe` has no `--model` flag in programmatic mode. Model selection happens
|
||||
* exclusively via `~/.vibe/config.toml` (set interactively via the `/config`
|
||||
* command inside Vibe per DOCS-3) — there is no CLI-flag surface OLP can use
|
||||
* to pass `model` per-request. ALIGNMENT.md Rule 2: the underlying authority
|
||||
* does not perform the operation, so OLP must not invent one. The IR's
|
||||
* `model` field is used by OLP for routing only; the Vibe CLI will use
|
||||
* whatever model is configured at the user level in `~/.vibe/config.toml`.
|
||||
* Pinning source: DeepWiki CLI commands reference enumeration (DOCS-4).
|
||||
* See also `irToMistral` (line 371-374) which records the same finding at
|
||||
* the spawn-args construction site.
|
||||
* (D36 #6: status flipped from UNPINNED-D-later-verifies → CONFIRMED-NOT-APPLICABLE.
|
||||
* Header status now matches the spawn-site finding that was already in place at D8.)
|
||||
*
|
||||
* A6 (exact model IDs — UNPINNED-D-later-verifies):
|
||||
* Name: model_ids
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "olp",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
||||
"type": "module",
|
||||
"main": "server.mjs",
|
||||
|
||||
+302
-41
@@ -29,7 +29,16 @@ import {
|
||||
generateRequestId,
|
||||
SSE_DONE,
|
||||
} from './lib/ir/ir-to-openai.mjs';
|
||||
import { loadProviders, listAllProviderNames, getAliasMap, getModelCreated } from './lib/providers/index.mjs';
|
||||
import {
|
||||
loadProviders,
|
||||
listAllProviderNames,
|
||||
getAliasMap,
|
||||
getModelCreated,
|
||||
tryAcquireSpawn,
|
||||
releaseSpawn,
|
||||
getActiveSpawnCount,
|
||||
DEFAULT_MAX_CONCURRENT_SPAWNS,
|
||||
} 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';
|
||||
@@ -263,6 +272,115 @@ function olpErrorHeaders({ startMs, model }) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── X-OLP-Fallback-Detail (D40, issue #7) ─────────────────────────────────
|
||||
|
||||
/**
|
||||
* 4KB UTF-8 byte cap on the X-OLP-Fallback-Detail header value. Per RFC 7230 §3.2.5
|
||||
* intermediaries are not obligated to forward arbitrarily large header values;
|
||||
* 4KB matches the conservative upper bound (well below the 8KB total-header
|
||||
* default of common reverse proxies — nginx `large_client_header_buffers`,
|
||||
* Apache `LimitRequestFieldSize`). Tuples beyond the cap are dropped and
|
||||
* a sentinel { truncated:true, omitted_hops:N } is appended.
|
||||
*/
|
||||
export const FALLBACK_DETAIL_BYTE_CAP = 4096;
|
||||
|
||||
/**
|
||||
* Serialises per-hop fallback failure tuples into the X-OLP-Fallback-Detail
|
||||
* header value. Returns null if the input array is empty/missing (header
|
||||
* should not be emitted in that case).
|
||||
*
|
||||
* D40 (issue #7) — see ADR 0004 § Observability headers.
|
||||
*
|
||||
* Cap behaviour:
|
||||
* - JSON.stringify the tuples; if Buffer.byteLength <= 4096, return as-is.
|
||||
* - Otherwise, drop tuples from the tail one at a time until the array PLUS
|
||||
* a trailing { truncated:true, omitted_hops:N } sentinel fits under the cap.
|
||||
* - If even a single tuple + sentinel cannot fit (extremely long error_message
|
||||
* beyond engine truncation, e.g. very long provider/model names), return
|
||||
* just the sentinel { truncated:true, omitted_hops:<all> } — never produce
|
||||
* a value > 4096 bytes.
|
||||
*
|
||||
* RFC 7230 hygiene: JSON.stringify already escapes raw newlines (\n → \\n),
|
||||
* carriage returns, and other control characters. In addition, we escape all
|
||||
* non-ASCII code points to \uXXXX sequences because Node's HTTP header
|
||||
* validator rejects multi-byte UTF-8 in field values (and RFC 7230 §3.2.6
|
||||
* limits `field-vchar` to ASCII VCHAR / obs-text). Without this step, an
|
||||
* em dash (U+2014) in a synthesised error message — e.g. the CONCURRENCY_LIMIT
|
||||
* message produced in server.mjs `collectAllChunks` — would trigger
|
||||
* `Invalid character in header content` from `res.writeHead`.
|
||||
*
|
||||
* @param {Array<object>|null|undefined} fallbackDetail — from FallbackResult.fallbackDetail
|
||||
* @returns {string|null} — header value, or null to skip emission
|
||||
*/
|
||||
export function serializeFallbackDetailHeader(fallbackDetail) {
|
||||
if (!Array.isArray(fallbackDetail) || fallbackDetail.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const full = jsonStringifyAscii(fallbackDetail);
|
||||
if (Buffer.byteLength(full, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP) {
|
||||
return full;
|
||||
}
|
||||
|
||||
// Cap exceeded — drop tail tuples until [...kept, sentinel] fits.
|
||||
// Linear scan from the full array down to 0 kept tuples. Worst case O(n^2)
|
||||
// on serialisation length, but n is bounded by chain length (small) so this
|
||||
// is fine in practice.
|
||||
for (let kept = fallbackDetail.length - 1; kept >= 0; kept--) {
|
||||
const omitted = fallbackDetail.length - kept;
|
||||
const sentinel = { truncated: true, omitted_hops: omitted };
|
||||
const candidate = jsonStringifyAscii([...fallbackDetail.slice(0, kept), sentinel]);
|
||||
if (Buffer.byteLength(candidate, 'utf8') <= FALLBACK_DETAIL_BYTE_CAP) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
// Even an array containing only the sentinel exceeds the cap — produce the
|
||||
// shortest possible valid sentinel value. This branch should be unreachable
|
||||
// for any realistic chain (the sentinel itself is ~45 bytes for omitted_hops
|
||||
// up to 9999).
|
||||
return jsonStringifyAscii([{ truncated: true, omitted_hops: fallbackDetail.length }]);
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON.stringify wrapper that escapes every non-ASCII code point as \uXXXX
|
||||
* so the result is safe to embed in an HTTP header value (RFC 7230 §3.2.6
|
||||
* field-vchar). JSON itself accepts both literal Unicode and \uXXXX escapes,
|
||||
* so JSON.parse round-trips correctly.
|
||||
*
|
||||
* Surrogate-pair handling: characters above U+FFFF (emoji etc.) are already
|
||||
* emitted as JS surrogate pairs by the string iterator; each surrogate is
|
||||
* a code unit in range 0xD800–0xDFFF, which our >= 0x80 guard catches.
|
||||
*
|
||||
* @param {unknown} value
|
||||
* @returns {string}
|
||||
*/
|
||||
function jsonStringifyAscii(value) {
|
||||
// The replace pattern is the UTF-8 literal byte range for code points
|
||||
// U+0080..U+FFFF (every non-ASCII BMP character). U+0080 is non-printable,
|
||||
// so the source line can render as the empty character class "[-...]" in
|
||||
// editors that hide it — the range is intentional and load-bearing for
|
||||
// RFC 7230 §3.2.6 compliance.
|
||||
return JSON.stringify(value).replace(/[-]/g, (ch) => {
|
||||
return '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merges X-OLP-Fallback-Detail into a base header object when the per-hop
|
||||
* failure tuples are non-empty. Returns the base object unchanged otherwise.
|
||||
*
|
||||
* D40 (issue #7).
|
||||
*
|
||||
* @param {Record<string,string>} baseHeaders
|
||||
* @param {Array<object>|null|undefined} fallbackDetail
|
||||
* @returns {Record<string,string>}
|
||||
*/
|
||||
function withFallbackDetailHeader(baseHeaders, fallbackDetail) {
|
||||
const value = serializeFallbackDetailHeader(fallbackDetail);
|
||||
if (value === null) return baseHeaders;
|
||||
return { ...baseHeaders, 'X-OLP-Fallback-Detail': value };
|
||||
}
|
||||
|
||||
// ── Route handlers ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -425,6 +543,26 @@ async function handleChatCompletions(req, res) {
|
||||
const hasCacheControlMarkers =
|
||||
hasCacheControl(ir) || extractCacheControlMarkers(body?.messages ?? []).length > 0;
|
||||
|
||||
// D36 #2 (ADR 0005 § D2): when cache_control markers are present AND at least one
|
||||
// hop in the chain is non-Anthropic, the markers are noop'd for those hops.
|
||||
// Per ADR 0005 § Context: "for non-Anthropic targets, the bypass markers are
|
||||
// noop'd (logged once per request at debug level so users can see they were
|
||||
// ignored)." Fires at most once per request, gated on (markers AND mixed/non-anthropic
|
||||
// chain). No log when no markers, or when every chain hop is Anthropic.
|
||||
if (hasCacheControlMarkers && chain.some(hop => hop.provider !== 'anthropic')) {
|
||||
// marker_count sums body-side and IR-side markers. At v0.1 the IR term is
|
||||
// structurally 0 (openAIToIR strips cache_control). When a future ADR 0003
|
||||
// amendment activates cache_control in the IR whitelist, both terms will be
|
||||
// non-zero for the same logical marker set → revisit to avoid 2× counting.
|
||||
const markerCount =
|
||||
extractCacheControlMarkers(body?.messages ?? []).length +
|
||||
extractCacheControlMarkers(ir.messages ?? []).length;
|
||||
logEvent('debug', 'cache_control_partial_noop', {
|
||||
chain: chain.map(hop => hop.provider),
|
||||
marker_count: markerCount,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if OLP's response cache should be bypassed for the given hop.
|
||||
* Per ADR 0005 § D2: bypass only when provider is Anthropic AND markers present.
|
||||
@@ -483,43 +621,80 @@ async function handleChatCompletions(req, res) {
|
||||
// conditions item 1 requires no truncation). A non-enumerable __truncated marker
|
||||
// is set on the returned array so executeHopFn can evict the cache entry below.
|
||||
async function collectAllChunks() {
|
||||
// D38 (issue #1): maxConcurrent runtime enforcement.
|
||||
// Authority: ADR 0002 Amendment 6 + ADR 0004 Amendment 4.
|
||||
//
|
||||
// Try to acquire a spawn slot for this provider BEFORE invoking spawn().
|
||||
// If at limit, synthesise ProviderError(CONCURRENCY_LIMIT) which the
|
||||
// fallback engine treats as a hard trigger — the chain advances to the
|
||||
// next hop. validateProvider guarantees hints.maxConcurrent is present;
|
||||
// DEFAULT_MAX_CONCURRENT_SPAWNS is a defense-in-depth fallback.
|
||||
const maxConcurrent = hopProviderPlugin.hints?.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_SPAWNS;
|
||||
if (!tryAcquireSpawn(hopProvider, maxConcurrent)) {
|
||||
const concurrencyErr = new ProviderError(
|
||||
`provider ${hopProvider} at maxConcurrent (${maxConcurrent}) — advancing to next hop`,
|
||||
'CONCURRENCY_LIMIT',
|
||||
);
|
||||
concurrencyErr.providerName = hopProvider;
|
||||
concurrencyErr.maxConcurrent = maxConcurrent;
|
||||
// activeSpawns reflects the live counter at the rejection moment,
|
||||
// queried directly. Since tryAcquireSpawn returned false the value
|
||||
// equals maxConcurrent — read it explicitly for diagnostic clarity
|
||||
// rather than echoing the limit (avoids future-reader confusion).
|
||||
concurrencyErr.activeSpawns = getActiveSpawnCount(hopProvider);
|
||||
throw concurrencyErr;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
// try/finally: releaseSpawn MUST fire on every exit path — success
|
||||
// (return at end), spawn throw (caught and re-thrown below), or the
|
||||
// D16 truncation-salvage return. The finally is the only mechanism
|
||||
// that guarantees release across all three.
|
||||
try {
|
||||
for await (const irChunk of hopProviderPlugin.spawn(irReq, authContext)) {
|
||||
// D16: check error chunks BEFORE pushing — preserves the invariant that
|
||||
// chunks array contains only delta/stop chunks. Without this, the catch
|
||||
// block's `chunks.length > 0` would mistake a single error chunk for
|
||||
// "usable content streamed" (Case B) and synthesize a stop + return,
|
||||
// sending an empty body to the client when the correct behavior is to
|
||||
// re-throw and let the fallback engine advance the chain.
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(
|
||||
irChunk.error ?? 'Provider emitted error chunk',
|
||||
'SPAWN_FAILED',
|
||||
);
|
||||
try {
|
||||
for await (const irChunk of hopProviderPlugin.spawn(irReq, authContext)) {
|
||||
// D16: check error chunks BEFORE pushing — preserves the invariant that
|
||||
// chunks array contains only delta/stop chunks. Without this, the catch
|
||||
// block's `chunks.length > 0` would mistake a single error chunk for
|
||||
// "usable content streamed" (Case B) and synthesize a stop + return,
|
||||
// sending an empty body to the client when the correct behavior is to
|
||||
// re-throw and let the fallback engine advance the chain.
|
||||
if (irChunk.type === 'error') {
|
||||
throw new ProviderError(
|
||||
irChunk.error ?? 'Provider emitted error chunk',
|
||||
'SPAWN_FAILED',
|
||||
);
|
||||
}
|
||||
chunks.push(irChunk);
|
||||
if (irChunk.type === 'stop') break;
|
||||
}
|
||||
chunks.push(irChunk);
|
||||
if (irChunk.type === 'stop') break;
|
||||
}
|
||||
} catch (spawnErr) {
|
||||
if (spawnErr instanceof ProviderError && spawnErr.code === 'SPAWN_FAILED' && chunks.length > 0) {
|
||||
// Case B (ADR 0004 Amendment 1): provider emitted usable chunks then exited
|
||||
// non-zero. Synthesize a truncated stop and surface the partial response.
|
||||
chunks.push({ type: 'stop', finish_reason: 'length' });
|
||||
logEvent('warn', 'spawn_failed_after_usable_chunks', {
|
||||
chunks_count: chunks.length - 1, // exclude the synthesized stop
|
||||
provider: hopProvider,
|
||||
model: hopModel,
|
||||
});
|
||||
// Mark as truncated so the caller can evict this entry from cache.
|
||||
Object.defineProperty(chunks, '__truncated', { value: true, enumerable: false });
|
||||
return chunks;
|
||||
} catch (spawnErr) {
|
||||
if (spawnErr instanceof ProviderError && spawnErr.code === 'SPAWN_FAILED' && chunks.length > 0) {
|
||||
// Case B (ADR 0004 Amendment 1): provider emitted usable chunks then exited
|
||||
// non-zero. Synthesize a truncated stop and surface the partial response.
|
||||
chunks.push({ type: 'stop', finish_reason: 'length' });
|
||||
logEvent('warn', 'spawn_failed_after_usable_chunks', {
|
||||
chunks_count: chunks.length - 1, // exclude the synthesized stop
|
||||
provider: hopProvider,
|
||||
model: hopModel,
|
||||
});
|
||||
// Mark as truncated so the caller can evict this entry from cache.
|
||||
Object.defineProperty(chunks, '__truncated', { value: true, enumerable: false });
|
||||
return chunks;
|
||||
}
|
||||
// Case A (SPAWN_FAILED with no chunks) or any other error: re-throw.
|
||||
// Fallback engine fires hard trigger and advances chain as before.
|
||||
throw spawnErr;
|
||||
}
|
||||
// Case A (SPAWN_FAILED with no chunks) or any other error: re-throw.
|
||||
// Fallback engine fires hard trigger and advances chain as before.
|
||||
throw spawnErr;
|
||||
return chunks;
|
||||
} finally {
|
||||
// D38: spawn lifecycle ended (drain completed, stop chunk received,
|
||||
// SPAWN_FAILED salvage returned, or any unexpected throw). Release
|
||||
// the slot so the next caller can acquire it. Single-threaded JS
|
||||
// guarantees no other caller has incremented this provider's count
|
||||
// between our tryAcquireSpawn() above and this releaseSpawn().
|
||||
releaseSpawn(hopProvider);
|
||||
}
|
||||
return chunks;
|
||||
}
|
||||
|
||||
// D23: cacheable opt-out check (ADR 0002 Amendment 3 + ADR 0005 Amendment 3).
|
||||
@@ -568,10 +743,26 @@ async function handleChatCompletions(req, res) {
|
||||
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
|
||||
// persist in cache. Overwrite with ttlMs=0 so any subsequent get/peek
|
||||
// finds the entry already-expired and deletes it.
|
||||
await cacheStore.set(keyId, hopCacheKey, result, 0);
|
||||
// ADR 0005 § "Cache write conditions" item 1: truncated responses must
|
||||
// not persist in cache.
|
||||
//
|
||||
// D39 (issue #3 Part 1): use explicit cacheStore.delete() rather than
|
||||
// the prior set(..., ttlMs=0) tombstone. delete() removes the entry
|
||||
// from the namespace Map immediately (and removes the empty namespace
|
||||
// entry from the outer Map if applicable), instead of waiting for the
|
||||
// next get/peek to lazily purge a TTL=0 entry.
|
||||
// Capture the boolean — false indicates a race (concurrent eviction or
|
||||
// TTL purge already removed the entry). Surfaces in the log so the
|
||||
// dashboard can distinguish "we evicted" from "we tried but it was gone."
|
||||
const evicted = cacheStore.delete(keyId, hopCacheKey);
|
||||
// D39 (issue #3 Part 2): observability — surface salvage frequency to
|
||||
// dashboards. Provider + model identify which hop's truncated entry was
|
||||
// evicted. cache_eviction_hit distinguishes actual-evict vs already-gone.
|
||||
logEvent('info', 'cache_evicted_truncated', {
|
||||
provider: hopProvider,
|
||||
model: hopModel,
|
||||
cache_eviction_hit: evicted,
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -606,7 +797,30 @@ async function handleChatCompletions(req, res) {
|
||||
//
|
||||
// On success: write chunks to res AND cache so subsequent identical requests
|
||||
// hit the burst-replay path.
|
||||
// D38 (issue #1): pre-acquire a concurrency slot for the streaming path so
|
||||
// saturation here behaves identically to saturation on the buffered path.
|
||||
// If acquire fails, fall through (skip this branch) — the buffered path
|
||||
// below also gates via tryAcquireSpawn and will surface a chain-exhausted
|
||||
// error through executeWithFallback (correct behaviour: a single-hop chain
|
||||
// at maxConcurrent has no other hop to advance to).
|
||||
//
|
||||
// Acquired here, released in the `finally` below. The gate intentionally
|
||||
// lives BEFORE the streaming-branch entry check so the buffered fallthrough
|
||||
// can re-attempt acquire from a clean slate.
|
||||
// TODO(v1.x — ADR 0005 Amendment 8 / issue #16): replace this peek+spawn
|
||||
// pattern with cacheStore.getOrComputeStreaming(...) to close the TOCTOU
|
||||
// window between line ~782 peek and the spawn at line ~846, and to make N
|
||||
// concurrent identical streaming requests share one spawn (tee-streaming).
|
||||
// Design ratified in D42; see docs/v1x-roadmap.md #1 for the trigger and
|
||||
// acceptance criteria. DO NOT remove this comment until the v1.x impl lands.
|
||||
let streamingAcquired = false;
|
||||
if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop && !preCheckHit && cacheableForFirstHop) {
|
||||
const candidatePlugin = loadedProviders.get(chain[0].provider);
|
||||
const candidateMax = candidatePlugin?.hints?.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_SPAWNS;
|
||||
streamingAcquired = candidatePlugin ? tryAcquireSpawn(chain[0].provider, candidateMax) : false;
|
||||
}
|
||||
|
||||
if (ir.stream && chain.length === 1 && !bypassCacheForFirstHop && !preCheckHit && cacheableForFirstHop && streamingAcquired) {
|
||||
const streamProvider = chain[0].provider;
|
||||
const streamModel = chain[0].model;
|
||||
const streamCacheKey = computeCacheKey(streamProvider, streamModel, ir);
|
||||
@@ -614,6 +828,8 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
if (!streamPlugin) {
|
||||
// Provider disappeared between chain build and here (edge case).
|
||||
// Release the slot we acquired above so the counter stays balanced.
|
||||
releaseSpawn(streamProvider);
|
||||
return sendError(res, 503, `Provider ${streamProvider} is not enabled`, 'no_enabled_provider',
|
||||
olpErrorHeaders({ startMs, model: ir.model }));
|
||||
}
|
||||
@@ -637,12 +853,16 @@ async function handleChatCompletions(req, res) {
|
||||
if (irChunk.type === 'error') {
|
||||
// Error chunk from provider
|
||||
if (firstChunkEmitted) {
|
||||
// Past first-chunk boundary — can't fallback; truncate stream.
|
||||
// Past first-chunk boundary — can't fallback; emit truncation marker + [DONE]
|
||||
// so clients can detect the incomplete response in-band (aligns D26 F19
|
||||
// stop-less exhaustion behaviour and the catch-block fix in D35 #10).
|
||||
logEvent('warn', 'streaming_error_after_first_chunk', {
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
error: irChunk.error,
|
||||
});
|
||||
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
@@ -701,6 +921,23 @@ async function handleChatCompletions(req, res) {
|
||||
const truncMarker = { type: 'stop', finish_reason: 'length' };
|
||||
res.write(irChunkToOpenAISSE(truncMarker, requestId, ir.model));
|
||||
}
|
||||
|
||||
// D35 #9: Zero-chunk empty-stream path — writeHead is still deferred
|
||||
// (firstChunkEmitted===false) when the generator yields no chunks at all and
|
||||
// exits cleanly. Without an explicit writeHead Node auto-emits 200 with the
|
||||
// default Content-Type and none of the X-OLP-* headers.
|
||||
// A provider that yielded nothing still constitutes an attempted call, so we
|
||||
// emit the full olpHeaders (provider WAS attempted, just yielded zero chunks).
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
...streamHeaders,
|
||||
});
|
||||
}
|
||||
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
// Loop exhausted without stop chunk = truncation. The stop-chunk completion
|
||||
@@ -716,12 +953,17 @@ async function handleChatCompletions(req, res) {
|
||||
}
|
||||
} catch (e) {
|
||||
if (firstChunkEmitted) {
|
||||
// Past first-chunk boundary — truncate silently.
|
||||
// Past first-chunk boundary — can't fallback; emit truncation marker + [DONE]
|
||||
// so clients can detect the incomplete response in-band (aligns with D26 F19
|
||||
// stop-less exhaustion behaviour). ADR 0004 § Fallback safety: no fallback
|
||||
// after first-chunk boundary; truncation is the correct recovery.
|
||||
logEvent('warn', 'streaming_error_after_first_chunk', {
|
||||
provider: streamProvider,
|
||||
model: streamModel,
|
||||
error: e.message,
|
||||
});
|
||||
res.write(irChunkToOpenAISSE({ type: 'stop', finish_reason: 'length' }, requestId, ir.model));
|
||||
res.write(SSE_DONE);
|
||||
res.end();
|
||||
} else {
|
||||
// No bytes written — surface a clean JSON error.
|
||||
@@ -737,6 +979,13 @@ async function handleChatCompletions(req, res) {
|
||||
res.end();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// D38 (issue #1): streaming spawn lifecycle ended — drain completed,
|
||||
// stop chunk seen, generator exhausted without stop, or any catch
|
||||
// path returned via res.end(). Release the slot acquired before
|
||||
// entering the streaming branch. The finally fires on every JS exit
|
||||
// path including the `return;` statements inside the try/catch body.
|
||||
releaseSpawn(streamProvider);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -760,6 +1009,7 @@ async function handleChatCompletions(req, res) {
|
||||
fallbackHops,
|
||||
originalError,
|
||||
triedProviders,
|
||||
fallbackDetail, // D40 (issue #7): per-hop failure tuples for X-OLP-Fallback-Detail
|
||||
} = fallbackResult;
|
||||
|
||||
// ── Chain exhausted or non-trigger error ─────────────────────────────────
|
||||
@@ -809,18 +1059,23 @@ async function handleChatCompletions(req, res) {
|
||||
fallbackHops: fallbackHops ?? 0,
|
||||
});
|
||||
|
||||
// Send error with standard OLP headers + optional exhausted header
|
||||
// Send error with standard OLP headers + optional exhausted header +
|
||||
// D40 X-OLP-Fallback-Detail (when any hop attempted to spawn failed).
|
||||
// D40 (issue #7) — ungated v0.1 per maintainer decision; owner-vs-non-owner
|
||||
// gating planned for Phase 2 with lib/keys.mjs.
|
||||
const payload = JSON.stringify({
|
||||
error: {
|
||||
message: originalError?.message ?? 'Provider error',
|
||||
type: 'provider_error',
|
||||
},
|
||||
});
|
||||
const detailHeader = withFallbackDetailHeader({}, fallbackDetail);
|
||||
res.writeHead(errStatus, {
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(payload),
|
||||
...errorOlpHeaders,
|
||||
...exhaustedHeader,
|
||||
...detailHeader,
|
||||
});
|
||||
res.end(payload);
|
||||
return;
|
||||
@@ -840,7 +1095,13 @@ async function handleChatCompletions(req, res) {
|
||||
const cacheStatus = bypassCacheForServingHop ? 'bypass'
|
||||
: (lastHopWasCached || (preCheckHit && fallbackHops === 0)) ? 'hit'
|
||||
: 'miss';
|
||||
const headers = olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops });
|
||||
// D40 (issue #7): when at least one prior hop failed before this success,
|
||||
// surface X-OLP-Fallback-Detail with the failure trail. Header is omitted
|
||||
// when fallbackDetail is empty (single-hop success).
|
||||
const headers = withFallbackDetailHeader(
|
||||
olpHeaders({ providerUsed, modelUsed, startMs, cacheStatus, fallbackHops }),
|
||||
fallbackDetail,
|
||||
);
|
||||
|
||||
if (ir.stream) {
|
||||
// Streaming response path: burst replay from buffered chunks.
|
||||
|
||||
+1772
-1
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user