From e96752a5288932b0d1912fd62a9134e2e658ee11 Mon Sep 17 00:00:00 2001 From: dtzp555 Date: Sun, 24 May 2026 20:38:16 +1000 Subject: [PATCH] =?UTF-8?q?docs+fix+test:=20D36=20=E2=80=94=20pre-Phase-2?= =?UTF-8?q?=20batch=20#2=20(issues=20#2=20#5=20#6=20#13=20#14=20#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second batch of pre-Phase-2 cleanup. 6 GitHub issues closed in one cohesive docs/governance commit with 1 small code addition (#2 debug log line in server.mjs) and 1 transcript artifact (#15 new file). Changes (7 files modified, 1 file created, +470 / -13): **Code changes** 1. **#2 — cache_control partial-noop debug log** (server.mjs) ADR 0005 § D2 says: "for non-Anthropic targets, the bypass markers are noop'd (logged once per request at debug level so users can see they were ignored)". Pre-D36 logEvent fired only when an Anthropic hop actually bypassed; non-Anthropic hops with markers present were silently noop'd. New 12-line block in handleChatCompletions right after hasCacheControlMarkers is computed. Fires logEvent('debug', 'cache_control_partial_noop', { chain: [], marker_count: }) when: - hasCacheControlMarkers === true AND - chain.some(hop => hop.provider !== 'anthropic') Fires at most once per request (top-level if, not in a loop). No log when no markers, or when every chain hop is Anthropic. The existing cache_bypass debug log inside shouldBypassCacheForHop is untouched. marker_count sums body-side and IR-side extractCacheControlMarkers results. At v0.1 the IR term is structurally 0 (openAIToIR strips cache_control); inline comment marks this as a revisit point for the future ADR 0003 amendment that activates cache_control in the IR whitelist. **Documentation amendments** 2. **#5 — ADR 0002 vibe.mjs → mistral.mjs** (docs/adr/0002-plugin-architecture.md) § Decision filesystem layout: `vibe.mjs` corrected to `mistral.mjs` (file named after provider key per the convention anthropic.mjs/codex.mjs). The vibe.mjs entry was an early-draft naming choice that never landed. Amendment 5 prepended above Amendment 4 documenting the filename correction + explicit convention statement (file named after provider key, not CLI binary) for future contributors. 3. **#6 — mistral.mjs A5 flip + ALIGNMENT.md table update** (lib/providers/mistral.mjs + ALIGNMENT.md) Pre-D36: header A5 (model flag) status was UNPINNED-D-later-verifies but function body lines 376-380 said CONFIRMED-NOT-APPLICABLE (DeepWiki enumeration already confirmed `--model` does not exist on vibe CLI). Header now reflects CONFIRMED-NOT-APPLICABLE with DeepWiki citation (DOCS-4). ALIGNMENT.md Speculative-Candidate table mistral row: A5 removed from UNPINNED list; A4, A6, A7, A8 preserved with parenthetical descriptions intact. No code change to function body (already correct). 4. **#13 — /v1/models alias entries — ALIGNMENT.md + spec-pin governance** (ALIGNMENT.md + docs/openai-spec-pin.md) Round-6 F10 flagged D27 F15's alias entries on /v1/models as borderline Rule 2(b) violation (OpenAI spec does not enumerate aliases as separate entries). Option C selected: keep current behavior, document the controlled deviation. - ALIGNMENT.md: new "Controlled deviations (entry-surface scope)" subsection under "Class-specific Exceptions". Entry 1 documents the /v1/models alias deviation with rationale (D27 F15 onboarding), formal contract reference, field constraints (owned_by matches canonical, created matches canonical, no invented fields), SPOT reference (getAliasMap()), and re- evaluation trigger. - docs/openai-spec-pin.md: new alias-surfacing subsection under GET /v1/models with full 4-field contract table (id/object/ created/owned_by), rationale, sourcing explanation, forward path. - server.mjs handleModels: NO CHANGE — behavior preserved. 5. **#15 — Anthropic v2.1.89 transcript artifact** (docs/provider-audits/anthropic.md NEW; ALIGNMENT.md + lib/providers/anthropic.mjs cross-references) Round-6 F12: ALIGNMENT.md anthropic row pin (v2.1.89, observed at D4) cited the plugin header; plugin header cited the observation date but no transcript. Circular per Rule 1 ("observed behaviour, transcript attached"). New file docs/provider-audits/anthropic.md (single living artifact, not version-specific): - Date of capture: 2026-05-24 - Observed `claude --version`: 2.1.132 (Claude Code) — captured today on the project maintainer's primary workstation - Plugin-pinned version: @anthropic-ai/claude-code v2.1.89 (from D4 implementation pin in ALIGNMENT.md Provider Authority Pins) - Version drift: honestly documented — pin is v2.1.89, live is v2.1.132, drift within tolerance, re-audit triggers named - Sample invocation: `claude -p --output-format text --no-session- persistence --model [--debug]` - Flag-surface table: 5 OLP-consumed flags verbatim from `claude -p --help` (-p / --output-format / --no-session- persistence / --model / --debug) - Citation cross-references back to ALIGNMENT.md + plugin header ALIGNMENT.md anthropic row: appended "transcript artifact: docs/ provider-audits/anthropic.md (captured 2026-05-24)" — closes the circular citation. lib/providers/anthropic.mjs header: 5-line pointer to the artifact with version numbers stated explicitly. **Tests** (test-features.mjs): 424 → 431 (+7): - #2 partial-noop log ×3: - Suite 9f case 1: markers + mixed chain → fires once at level=debug - Suite 9f case 2: no markers → suppressed - Suite 9f case 3: anthropic-only chain → suppressed - #14 cache_control slot determinism ×4: - #14a: markers-present IR produces different key from no-markers IR - #14b: same IR computed twice yields identical key - #14c: two independently-constructed IRs with identical payloads yield same key - #14d: both top-level and content-array-nested markers affect the key All 4 tests call computeCacheKey directly on hand-built IRs (bypassing openAIToIR which strips markers at v0.1). Per ALIGNMENT.md Rule 2 (No Invention), no sortMarkers helper added — the slot is dead-code at v0.1 and shipping a helper without a caller authority would be invention. Test comment documents the forward-activation contract. Pre-commit fold-in (per evidence-first checkpoint #4): - **D36 reviewer flagged marker_count latent double-count risk** (Suggestion #1, non-blocking). The sum at server.mjs:435-437 is safe at v0.1 (IR term structurally 0) but will 2× when a future ADR 0003 amendment activates cache_control in the IR whitelist. Folded in a 4-line comment marking the revisit point. Two other non-blocking reviewer suggestions not folded: - Test count brief-vs-deliverable discrepancy (4 not 3 for #14) is informational — the 4-test variant is strictly better (covers content-array nesting which is a real extractCacheControlMarkers contract path). - Recapture-procedure git-add reminder in anthropic.md is low-priority procedure documentation. Authority: - ADR 0005 § D2 — cache_control partial-noop debug log requirement (#2) - ADR 0002 § Decision filesystem layout (Amendment 5) — plugin file naming convention (#5) - DeepWiki vibe CLI flag enumeration — A5 not applicable (#6) - ALIGNMENT.md Rule 2(b) + docs/openai-spec-pin.md GET /v1/models — alias controlled deviation (#13) - ADR 0005 cache key stability invariant + ADR 0003 forward-compat — cache_control slot determinism contract (#14) - ALIGNMENT.md Rule 5 (observed behaviour, transcript attached) + Provider Authority Pins anthropic row — transcript artifact (#15) - CC 开发铁律 v1.6 § 10.x — independent fresh-context reviewer - CLAUDE.md release_kit_overlay phase_rolling_mode — D36 under "Unreleased" against Phase 2; no version bump Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent of drafter): APPROVE. Critical depth checks: - #2: gate condition fires only on (markers AND non-anthropic-hop); pre-existing cache_bypass log inside shouldBypassCacheForHop untouched - #5: lib/providers/ directory verified — mistral.mjs exists, vibe.mjs does not exist - #6: mistral.mjs spawn-site body comment (lines 376-380) already CONFIRMED-NOT-APPLICABLE pre-D36 and unchanged in this diff - #13: server.mjs handleModels unchanged (verified via grep) - #14: all 4 tests pass against current code; no sortMarkers helper shipped (Rule 2 No Invention honored) - #15: live `claude --version` independently run by reviewer → matches artifact (2.1.132); all 5 OLP-consumed flags independently verified present in `claude -p --help` - Hygiene: 0 hits for personal markers, home paths, OAuth tokens, internal IPs across all 8 files - 431/431 tests pass in reviewer's independent npm test run Co-Authored-By: Claude Opus 4.7 --- ALIGNMENT.md | 14 +- docs/adr/0002-plugin-architecture.md | 13 +- docs/openai-spec-pin.md | 32 +++ docs/provider-audits/anthropic.md | 124 +++++++++ lib/providers/anthropic.mjs | 6 + lib/providers/mistral.mjs | 25 +- server.mjs | 20 ++ test-features.mjs | 370 +++++++++++++++++++++++++++ 8 files changed, 591 insertions(+), 13 deletions(-) create mode 100644 docs/provider-audits/anthropic.md diff --git a/ALIGNMENT.md b/ALIGNMENT.md index 929bf45..bc99585 100644 --- a/ALIGNMENT.md +++ b/ALIGNMENT.md @@ -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/.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: `, `object: 'model'`, `owned_by: `, and `created: ` 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 diff --git a/docs/adr/0002-plugin-architecture.md b/docs/adr/0002-plugin-architecture.md index 78cb127..58e19b0 100644 --- a/docs/adr/0002-plugin-architecture.md +++ b/docs/adr/0002-plugin-architecture.md @@ -7,6 +7,15 @@ ## Amendments +### 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 +69,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 diff --git a/docs/openai-spec-pin.md b/docs/openai-spec-pin.md index 301621e..749fd44 100644 --- a/docs/openai-spec-pin.md +++ b/docs/openai-spec-pin.md @@ -136,6 +136,38 @@ Each entry: `{ "id": "", "object": "model", "created": , "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 diff --git a/docs/provider-audits/anthropic.md b/docs/provider-audits/anthropic.md new file mode 100644 index 0000000..995d966 --- /dev/null +++ b/docs/provider-audits/anthropic.md @@ -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 [--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 ` 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 ` | 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 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). diff --git a/lib/providers/anthropic.mjs b/lib/providers/anthropic.mjs index e3b57f3..f84a5fe 100644 --- a/lib/providers/anthropic.mjs +++ b/lib/providers/anthropic.mjs @@ -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) diff --git a/lib/providers/mistral.mjs b/lib/providers/mistral.mjs index 9cd0eed..9a3f1f1 100644 --- a/lib/providers/mistral.mjs +++ b/lib/providers/mistral.mjs @@ -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 diff --git a/server.mjs b/server.mjs index 7b8f353..51bbf65 100644 --- a/server.mjs +++ b/server.mjs @@ -425,6 +425,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. diff --git a/test-features.mjs b/test-features.mjs index 8d847ab..b1d2fa5 100644 --- a/test-features.mjs +++ b/test-features.mjs @@ -1543,6 +1543,160 @@ describe('Cache layer — computeCacheKey (Suite 9)', () => { }); }); +// ── D36 #14: cache_control slot determinism regression ───────────────────── +// +// Context (issue #14 + ADR 0005 Amendment 4): +// computeCacheKey at lib/cache/keys.mjs:~224 calls extractCacheControlMarkers +// on ir.messages. openAIToIR (lib/ir/openai-to-ir.mjs translateMessage) does +// NOT whitelist cache_control, so v0.1 IRs constructed from OpenAI requests +// always have markers stripped → the slot is structurally null. D27 F10 +// Amendment 4 acknowledged this is forward-compatible: when a future ADR 0003 +// amendment adds cache_control to the IR field set, the slot starts carrying +// meaningful data without a cache-key schema change. +// +// What this suite guards: +// 1. The cache_control slot IS populated when markers are present on the IR +// (forward-compat scenario — markers attached directly to an IR object +// bypassing the openAIToIR translation). +// 2. Computing the key twice with the same marker payload yields identical +// keys (determinism — bedrock invariant per ADR 0005 § cache key stability). +// 3. The slot is included in the composition — two IRs differing ONLY in +// cache_control markers produce different keys. +// +// Future activation contract (when ADR 0003 adds cache_control to the IR +// whitelist and openAIToIR starts preserving markers): +// - extractCacheControlMarkers must continue to return markers in messages- +// iteration order; nested-in-content markers follow the outer message in +// positional order (see lib/cache/keys.mjs:126-148). +// - The current implementation JSON.stringifies the marker array verbatim +// (no pre-sort) — determinism relies on messages-array order being stable +// across translations and on per-marker object key insertion order being +// stable (which it is for objects authored at one source site). +// - If a future amendment introduces semantic equivalences across marker +// orderings (e.g., "two markers in different positions should still hit +// the same cache entry"), the implementation must add a sortMarkers helper +// before serialization. This test would then need an update to assert the +// normalized-order behavior. As of D36, no such equivalence is claimed; the +// test asserts the strict "same input → same output" determinism only. +// +// Risk path chosen: option (a) test-only (no code change in lib/cache/keys.mjs). +// Rationale: the dead-code path is unreachable from openAIToIR at v0.1, so a +// helper added now would have zero callers; preferred to defer the helper until +// the IR amendment actually activates the slot. Per ALIGNMENT.md Rule 2 (No +// Invention), shipping a sortMarkers helper today would be invention without a +// caller authority. Adding tests is risk-free; adding helpers is not. + +describe('D36 #14 — cache_control slot determinism regression', () => { + + // Construct an IR with synthetic cache_control markers attached directly + // (bypass openAIToIR which strips them at v0.1). + function makeIRWithMarkers(markers, opts = {}) { + const messages = opts.messages ?? [ + { role: 'user', content: 'sample-prompt' }, + ]; + // Attach the first marker to the first message; if a second marker is + // provided, attach it to the second message (or to message 0 nested in a + // content array, depending on opts.nestSecond). + const annotated = messages.map((m, idx) => { + if (idx === 0 && markers[0]) { + return { ...m, cache_control: markers[0] }; + } + if (idx === 1 && markers[1]) { + return { ...m, cache_control: markers[1] }; + } + return m; + }); + return makeIR({ messages: annotated, ...opts.irOverrides }); + } + + it('D36 #14a: cache_control slot is populated when markers are present on the IR', () => { + // The slot is normally null at v0.1 (openAIToIR strips). When markers ARE + // present (e.g. an IR constructed directly with markers, or after a future + // ADR 0003 amendment preserves them), the slot carries the markers. + const irNoMarkers = makeIR({ messages: [{ role: 'user', content: 'hi' }] }); + const irWithMarker = makeIRWithMarkers([{ type: 'ephemeral' }], { + messages: [{ role: 'user', content: 'hi' }], + }); + + const kNo = computeCacheKey('anthropic', 'claude-haiku-4-5', irNoMarkers); + const kYes = computeCacheKey('anthropic', 'claude-haiku-4-5', irWithMarker); + + // Different keys: marker presence is observable in the cache key. + assert.notEqual(kNo, kYes, + 'IR with cache_control markers must produce a different cache key from IR without markers'); + assert.equal(typeof kYes, 'string'); + assert.equal(kYes.length, 64); + }); + + it('D36 #14b: cache key is deterministic across two computations on the same IR with markers', () => { + // The bedrock invariant: same inputs → same key. The cache_control slot + // must not introduce nondeterminism (e.g., timestamp, random, iteration + // order from a Map). + const ir = makeIRWithMarkers([{ type: 'ephemeral' }], { + messages: [ + { role: 'user', content: 'first turn' }, + { role: 'assistant', content: 'first reply' }, + ], + }); + const k1 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir); + const k2 = computeCacheKey('anthropic', 'claude-haiku-4-5', ir); + assert.equal(k1, k2, 'cache key must be deterministic for the same IR with markers'); + }); + + it('D36 #14c: same markers in the same positional order produce the same key', () => { + // Two IRs constructed independently with the same marker payload at the + // same positions must produce the same key. This is the forward-activation + // contract: when openAIToIR preserves markers, two identical OpenAI + // requests must produce identical cache keys. + const irA = makeIRWithMarkers([{ type: 'ephemeral' }], { + messages: [{ role: 'user', content: 'hello' }], + }); + const irB = makeIRWithMarkers([{ type: 'ephemeral' }], { + messages: [{ role: 'user', content: 'hello' }], + }); + const kA = computeCacheKey('anthropic', 'claude-haiku-4-5', irA); + const kB = computeCacheKey('anthropic', 'claude-haiku-4-5', irB); + assert.equal(kA, kB, + 'two independently-constructed IRs with identical marker payloads must share a cache key'); + }); + + it('D36 #14d: markers nested in content array participate in the cache key (extractCacheControlMarkers contract)', () => { + // extractCacheControlMarkers (lib/cache/keys.mjs:126-148) finds markers at + // both message top-level AND nested inside content array parts. The cache + // key must reflect both surfaces. This is the slot's full forward-compat + // contract — when the IR carries cache_control, OLP must observe it on the + // wire shape the IR provides, including the OpenAI-style content-array + // nesting that Anthropic prompt-caching uses in production. + const irTopLevel = makeIR({ + messages: [ + { role: 'user', content: 'hi', cache_control: { type: 'ephemeral' } }, + ], + }); + const irNested = makeIR({ + messages: [ + { + role: 'user', + content: [ + { type: 'text', text: 'hi', cache_control: { type: 'ephemeral' } }, + ], + }, + ], + }); + const irNoMarkers = makeIR({ + messages: [{ role: 'user', content: 'hi' }], + }); + + const kTop = computeCacheKey('anthropic', 'claude-haiku-4-5', irTopLevel); + const kNested = computeCacheKey('anthropic', 'claude-haiku-4-5', irNested); + const kNone = computeCacheKey('anthropic', 'claude-haiku-4-5', irNoMarkers); + + // Both marker surfaces must register against the key (be observably distinct + // from the no-markers case). + assert.notEqual(kTop, kNone, 'top-level cache_control marker must affect cache key'); + assert.notEqual(kNested, kNone, 'nested cache_control marker must affect cache key'); + }); +}); + describe('Cache layer — extractCacheControlMarkers + hasCacheControl (Suite 9 cont.)', () => { // ── Test 9: extractCacheControlMarkers — top-level ─────────────────── @@ -2425,6 +2579,222 @@ describe('D13 — cache_control per-hop bypass correctness (Suite 9d)', () => { }); }); +// ── Suite 9f: D36 #2 cache_control partial-noop debug log ──────────────── +// +// Authority: 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)." +// +// Contract: at request entry in handleChatCompletions, after hasCacheControlMarkers +// is computed, emit ONE logEvent('debug', 'cache_control_partial_noop', { chain, marker_count }) +// IF AND ONLY IF (a) markers are present AND (b) at least one chain hop is +// non-Anthropic. The log is suppressed when no markers, or when every hop is +// Anthropic. +// +// Implementation strategy: monkeypatch process.stdout.write (debug events go +// to stdout per the logEvent helper in server.mjs:56-63) and grep for the +// event name across the captured writes. + +describe('D36 #2 — cache_control partial-noop debug log (Suite 9f)', () => { + let serverD36; + let portD36; + let savedAnthropicTokenD36; + let savedCodexAuthPathD36; + let suiteCodexAuthFileD36; + + // ── stdout capture helpers ─────────────────────────────────────────── + let stdoutWrites = []; + let origStdoutWrite = null; + + function startStdoutCapture() { + stdoutWrites = []; + origStdoutWrite = process.stdout.write.bind(process.stdout); + process.stdout.write = (chunk, ...rest) => { + const s = typeof chunk === 'string' + ? chunk + : (Buffer.isBuffer(chunk) ? chunk.toString() : String(chunk)); + stdoutWrites.push(s); + return origStdoutWrite(chunk, ...rest); + }; + } + + function stopStdoutCapture() { + if (origStdoutWrite) { + process.stdout.write = origStdoutWrite; + origStdoutWrite = null; + } + } + + /** Returns array of parsed JSON events whose `event` field === eventName. */ + function findEvents(eventName) { + const found = []; + for (const w of stdoutWrites) { + for (const line of w.split('\n')) { + if (!line.trim()) continue; + if (!line.includes(`"event":"${eventName}"`)) continue; + try { + const parsed = JSON.parse(line); + if (parsed?.event === eventName) found.push(parsed); + } catch { /* not JSON — ignore */ } + } + } + return found; + } + + before(async () => { + savedAnthropicTokenD36 = process.env.CLAUDE_CODE_OAUTH_TOKEN; + process.env.CLAUDE_CODE_OAUTH_TOKEN = 'fake-anthropic-token-for-d36-suite'; + + const { writeFileSync } = await import('node:fs'); + const { tmpdir } = await import('node:os'); + const { join: pathJoin } = await import('node:path'); + suiteCodexAuthFileD36 = pathJoin(tmpdir(), `olp-test-d36-codex-auth-${Date.now()}.json`); + writeFileSync(suiteCodexAuthFileD36, JSON.stringify({ accessToken: 'fake-codex-token-for-d36-suite' }), 'utf8'); + savedCodexAuthPathD36 = process.env.OPENAI_CODEX_AUTH_PATH; + process.env.OPENAI_CODEX_AUTH_PATH = suiteCodexAuthFileD36; + + const testProviders = loadProviders({ enabled: { anthropic: true, openai: true } }); + const { loadedProviders: lp, cacheStore: cs } = await import('./server.mjs'); + for (const [name, p] of testProviders) { + lp.set(name, p); + } + cs.clear(); + + serverD36 = createOlpServer(); + portD36 = 22000 + Math.floor(Math.random() * 500); + await new Promise((resolve, reject) => { + serverD36.listen(portD36, '127.0.0.1', resolve); + serverD36.once('error', (e) => { + if (e.code === 'EADDRINUSE') { + portD36++; + serverD36.listen(portD36, '127.0.0.1', resolve); + serverD36.once('error', reject); + } else reject(e); + }); + }); + }); + + after(async () => { + stopStdoutCapture(); + __resetSpawnImpl(); + codexResetSpawnImpl(); + + if (savedAnthropicTokenD36 !== undefined) { + process.env.CLAUDE_CODE_OAUTH_TOKEN = savedAnthropicTokenD36; + } else { + delete process.env.CLAUDE_CODE_OAUTH_TOKEN; + } + if (savedCodexAuthPathD36 !== undefined) { + process.env.OPENAI_CODEX_AUTH_PATH = savedCodexAuthPathD36; + } else { + delete process.env.OPENAI_CODEX_AUTH_PATH; + } + if (suiteCodexAuthFileD36) { + const { unlinkSync } = await import('node:fs'); + try { unlinkSync(suiteCodexAuthFileD36); } catch { /* ignore */ } + } + + if (!serverD36) return; + return new Promise(r => serverD36.close(r)); + }); + + it('D36 #2a: markers + non-Anthropic chain → cache_control_partial_noop log fires once', async () => { + // Route to openai (gpt-5.5) with cache_control markers present. Per ADR 0005 + // § Context, the partial-noop log must fire because the chain contains a + // non-Anthropic hop and markers are present. + codexSetSpawnImpl(makeMockCodexSpawn([ + JSON.stringify({ content: 'codex-out-d36-2a' }), + JSON.stringify({ type: 'stop' }), + ])); + + startStdoutCapture(); + try { + const r = await fetch({ + port: portD36, + method: 'POST', + path: '/v1/chat/completions', + body: { + model: 'gpt-5.5', + messages: [ + { role: 'user', content: 'hello-d36-2a', cache_control: { type: 'ephemeral' } }, + ], + }, + }); + assert.equal(r.status, 200, `Expected 200, got ${r.status}: ${r.body.slice(0, 200)}`); + } finally { + stopStdoutCapture(); + } + + const events = findEvents('cache_control_partial_noop'); + assert.equal(events.length, 1, `Expected exactly 1 cache_control_partial_noop event, got ${events.length}`); + const ev = events[0]; + assert.equal(ev.level, 'debug', `event level should be 'debug', got '${ev.level}'`); + assert.ok(Array.isArray(ev.chain), 'chain must be an array'); + assert.ok(ev.chain.includes('openai'), + `chain should include 'openai', got: ${JSON.stringify(ev.chain)}`); + assert.equal(typeof ev.marker_count, 'number', 'marker_count must be a number'); + assert.ok(ev.marker_count >= 1, `marker_count must be >= 1, got ${ev.marker_count}`); + }); + + it('D36 #2b: no markers + non-Anthropic chain → cache_control_partial_noop does NOT fire', async () => { + codexSetSpawnImpl(makeMockCodexSpawn([ + JSON.stringify({ content: 'codex-out-d36-2b' }), + JSON.stringify({ type: 'stop' }), + ])); + + startStdoutCapture(); + try { + const r = await fetch({ + port: portD36, + method: 'POST', + path: '/v1/chat/completions', + body: { + model: 'gpt-5.5', + messages: [ + { role: 'user', content: 'hello-d36-2b' }, // no cache_control + ], + }, + }); + assert.equal(r.status, 200, `Expected 200, got ${r.status}: ${r.body.slice(0, 200)}`); + } finally { + stopStdoutCapture(); + } + + const events = findEvents('cache_control_partial_noop'); + assert.equal(events.length, 0, + `Expected 0 cache_control_partial_noop events when no markers; got ${events.length}: ${JSON.stringify(events)}`); + }); + + it('D36 #2c: markers + anthropic-only chain → cache_control_partial_noop does NOT fire', async () => { + // anthropic single-hop chain — every hop is anthropic, so the partial-noop + // log must be suppressed. The cache_bypass log (already documented) is the + // correct signal for this case, not partial_noop. + __setSpawnImpl(makeMockSpawn(['anthropic-out-d36-2c'])); + + startStdoutCapture(); + try { + const r = await fetch({ + port: portD36, + method: 'POST', + path: '/v1/chat/completions', + body: { + model: 'claude-haiku-4-5', + messages: [ + { role: 'user', content: 'hello-d36-2c', cache_control: { type: 'ephemeral' } }, + ], + }, + }); + assert.equal(r.status, 200, `Expected 200, got ${r.status}: ${r.body.slice(0, 200)}`); + } finally { + stopStdoutCapture(); + } + + const events = findEvents('cache_control_partial_noop'); + assert.equal(events.length, 0, + `Expected 0 cache_control_partial_noop events when chain is anthropic-only; got ${events.length}: ${JSON.stringify(events)}`); + }); +}); + // ── Suite 11: Codex plugin (D6) ────────────────────────────────────────── // // All tests are UNIT tests. No real `codex` binary is invoked.