Files
olp/docs/adr/0003-intermediate-representation.md
T
taodengandClaude Opus 4.7 30de965e8e fix+docs: D32 — round-4 cleanup batch (F2/F3/F4/F5/F7/F8/F9)
cold-audit catch from 2026-05-24 (round 4)

Round-4 cold-audit cleanup batch. 7 items grouped per IDR cleanup-batch
convention (D19/D20/D25/D26/D30/D31 precedent). 2 P2 + 3 ADR amendments
+ 2 small docs/code cleanups. F10 (P3 semantic edge case) filed
separately as GitHub issue #8.

Changes (8 files, +200 / -38):

**P2 fixes**

1. **F3 — README missing 6 provider auth env vars** (README.md):
   D30 fixed the `OLP_*` table but missed the auth-bearing env vars
   actually read by plugin code:
   - `CLAUDE_CODE_OAUTH_TOKEN` (anthropic.mjs:84) — highest-precedence
     override; bypasses keychain + .credentials.json file lookup
   - `OPENAI_CODEX_AUTH_PATH` (codex.mjs:163) — overrides full auth
     file path; when set, no other path tried
   - `CODEX_HOME` (codex.mjs:176) — overrides base dir; default auth
     path becomes `$CODEX_HOME/auth.json`
   - `MISTRAL_API_KEY` (mistral.mjs:260) — directly supplies API key;
     highest precedence per Mistral DOCS-2
   - `MISTRAL_VIBE_AUTH_PATH` (mistral.mjs:265) — overrides full .env
     path; evaluated only when MISTRAL_API_KEY absent
   - `VIBE_HOME` (mistral.mjs:277) — overrides Vibe base dir; default
     auth path becomes `$VIBE_HOME/.env`
   Real onboarding-blocker fix: new users couldn't run OLP without
   knowing about these env vars; README now documents them in a
   "Per-provider auth env vars" subsection.

2. **F8 — Early-return error paths missing X-OLP-* headers** (server.mjs):
   ADR 0004 § Observability + README claim "every response carries the
   5 X-OLP-* headers" but 503 (no-enabled-providers), 415 (wrong
   Content-Type), and early 400s (bad JSON, IR validation) emitted only
   Content-Type + Content-Length + X-OLP-Latency-Ms (D18 only wired the
   chain-exhausted path). Operators debugging these paths got less info
   than the ADR promised.
   New helper `olpErrorHeaders({ startMs, model })` emits the canonical
   "no provider attempted" defaults:
     X-OLP-Provider-Used: 'none'
     X-OLP-Model-Used: model ?? 'unknown'
     X-OLP-Fallback-Hops: '0'
     X-OLP-Cache: 'bypass'
     X-OLP-Latency-Ms: <delta>
   6 sendError call sites updated: 415 / 400-bad-JSON / 400-IR-parse
   (model: undefined → 'unknown') + 503-no-providers / 503-provider-
   disappeared / 500-engine-programming (model: ir.model). The 502
   streaming-error-before-first-chunk path correctly stays on
   `olpHeaders(...)` since a provider WAS attempted.

**ADR amendments**

3. **F2 — ADR 0003 model-mapping example correction** (docs/adr/0003,
   Amendment 2): the § Required fields example claimed `claude-sonnet-4-6`
   → `claude-sonnet-4-6-20260301` mapping happens in the provider plugin.
   This was wrong: per D17 SPOT decision (commit cb86807), `irRequest.model`
   is passed verbatim to the CLI; each provider CLI accepts its own
   aliases natively. Both inline correction (in § Decision) AND new
   Amendment 2 (in § Amendments) added.

4. **F4 — OUTPUT_PARSE_ERROR dead code removal** (base.mjs, engine.mjs):
   `PROVIDER_ERROR_CODES.OUTPUT_PARSE_ERROR` and
   `HARD_TRIGGER_CODES.OUTPUT_PARSE_ERROR = true` were registered but
   ZERO plugins emit OUTPUT_PARSE_ERROR. ADR 0004 § Trigger taxonomy
   enumerates 4 hard-trigger categories (5xx, quota 4xx, exit-code,
   spawn-timeout) — OUTPUT_PARSE_ERROR was a 5th never authorized by
   ADR. Removed from both enums with removal-reason comments for
   auditability. Re-add via ADR 0004 amendment if a future plugin
   surfaces parse failures (cheaper than authoring an amendment for
   dead code now).

5. **F5 — ADR 0002 Amendment 4 ratifying contractVersion** (docs/adr/0002):
   `lib/providers/base.mjs:76-81` enforces `p.contractVersion === '1.0'`
   and all 3 plugins declare it, but ADR 0002 § Provider contract field
   list never named it. Same governance-violation class as D11's
   `maxSpawnTimeMs` retroactive sync (Amendment 1). Amendment 4 ratifies
   contractVersion as a required v1.0 contract field; § Provider contract
   field list updated to 10 fields (was 9).

**Small cleanups**

6. **F7 — README API Endpoints table 📋 markers** (README.md): added
   Status column with  Shipped (3 rows: /v1/chat/completions, /v1/models,
   /health) and 📋 Planned (3 rows: /cache/stats Phase 5, /v0/management/
   quota Phase 6, /dashboard Phase 6). Matches D20's Implementation
   Status table convention.

7. **F9 — Codex parser inline assumption labels** (codex.mjs): added 5
   inline `// A4:` comments on each defensive branch in `codexChunkToIR`.
   Pre-D32 these branches had only the top-of-function block comment;
   ALIGNMENT.md Speculative-Candidate condition 5 promises future
   implementers can grep assumption labels to find every speculative
   branch — D32 honors that promise for codex.mjs (mistral.mjs already
   had this pattern from D8).

**Tests** (test-features.mjs):
- 17g retitled + assertion expanded to full 5-header set (was partial)
- 17h new: 415 wrong Content-Type → 5 X-OLP-* headers with 'unknown' model
- 17i new: 503 no-enabled-providers → 5 X-OLP-* headers with ir.model
- OUTPUT_PARSE_ERROR test removed (replaced with comment for audit trail)

Test count: 400 → 401 (-1 OUTPUT_PARSE_ERROR + 2 new F8 + 1 retitled
existing = net +1).

**F10 filed as issue, NOT in D32**:
https://github.com/dtzp555-max/olp/issues/8 — "Soft-skip + chain-
exhausted: X-OLP-Provider-Used semantics ambiguity". Semantic edge
case requiring soft trigger reactivation (deferred to v1.x per D22
Amendment 2); not a v0.1 user-affecting issue.

Pre-commit fold-in (per evidence-first checkpoint #4):
- **D32 reviewer caught a doc-accuracy bug**: README's
  CLAUDE_CODE_OAUTH_TOKEN row described the no-env fallback chain as
  "Searches keychain first, then `~/.claude/.credentials.json`" — but
  the actual code in anthropic.mjs:82-112 checks file FIRST, then
  keychain (darwin-only). Order was reversed. Folded in: corrected
  to "Searches `~/.claude/.credentials.json` first, then macOS
  keychain (darwin only)". Same class of doc-accuracy mistake as the
  D25 → D31 / D27 → D31 pattern: D-batch reviewer catches docs that
  contradict source.

Reviewer (Iron Rule v1.6 § 10.x Mode A, fresh-context opus, independent
of drafter): APPROVE_WITH_MINOR. Independently verified:
- All 6 F3 env vars: precedence chains traced through plugin source
  (caught the anthropic reversed-order — folded in)
- F8 olpErrorHeaders helper correctly distinguishes "no provider"
  defaults from olpHeaders' "provider attempted" defaults
- F8: all 6 sendError call sites use the right model arg (undefined
  pre-IR-parse → 'unknown'; ir.model post-IR-parse)
- F8: 502 streaming-error stays on olpHeaders (provider was attempted)
- F4: OUTPUT_PARSE_ERROR removed from both enums, zero plugin emit
  references remain, test cleanup is auditable
- F5: ADR 0002 Amendment 4 matches Amendment 1's retroactive-sync
  structure; contract field list now 10 items
- F2: D17 commit cb86807 verified to match the SPOT decision claim
- F7/F9: docs/code cleanups verified
- F10 issue #8 verified OPEN with correct title
- 401/401 tests pass

3 non-blocking suggestions (anthropic precedence column folded; F2
inline mistral `--model` caveat could be added; F4 wording asymmetry
between base.mjs and engine.mjs comments) — minor polish, not folded.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-24 17:28:42 +10:00

132 lines
16 KiB
Markdown

# ADR 0003 — Intermediate Representation (IR) Design
- **Date:** 2026-05-23
- **Status:** Accepted (bootstrap)
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:** OLP v0.1 spec §4.1, §4.2 (IR subsection); ADR 0002 (plugin architecture); ADR 0004 (fallback engine — requires shape-stable requests for safe replay)
## Amendments
### Amendment 2 — 2026-05-24: Correct model-mapping example; document verbatim-pass-through design (D32 F2)
- **Finding:** Round-4 cold-audit F2 (P3 ADR example vs implementation drift) — § Decision "Required fields" item `model` reads: "The provider plugin maps this to the provider-native model identifier (e.g., `claude-sonnet-4-6``claude-sonnet-4-6-20260301` for Anthropic)." This is WRONG per the D17 SPOT decision (commit `cb86807`): OLP does NOT perform a model-alias mapping inside the provider plugin. `irRequest.model` is passed verbatim to the provider CLI (`claude -p --model <model>`, `codex exec --model <model>`, etc.); each provider's CLI resolves its own aliases natively per its documented behaviour.
- **Decision:** Update the `model` field description in § Decision to reflect the actual verbatim-pass-through design. The erroneous example (`claude-sonnet-4-6``claude-sonnet-4-6-20260301`) implied OLP maintained an explicit alias map, which it does not. See also inline update in § Decision below.
- **Authority:** D17 SPOT decision (commit `cb86807`) — "provider plugin passes `irRequest.model` verbatim; CLI alias resolution is the provider's responsibility." ADR 0003 § Decision is updated in-place to match; the original v1.0 text is preserved as a struck annotation.
- **Forward note:** v1.x may add an explicit OLP-side mapping step if any provider's CLI drops alias support or if a unified OLP-owned alias layer is needed across providers. That change requires a further ADR 0003 amendment.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-4 Cold Audit caught it as F2).
### Amendment 1 — 2026-05-24: Remove __irRoundTripTest() claim; document substitute test strategy (D31 F5)
- **Finding:** Round-3 cold-audit F5 (P2 ADR-claim vs implementation drift) — § Mitigations names a `__irRoundTripTest()` export on every provider plugin as the structural counter-measure against silent lossy translation. ZERO plugins export this function; the export-name was an early-design aspiration that does not fit the actual plugin shape (spawn-wrappers with asymmetric request→CLI-args+stdin and response-stream→IR, not symmetric `irToNative`+`nativeToIR` functions that could be tested in isolation via a round-trip fixture).
- **Decision:** Remove the `__irRoundTripTest()` export-name claim from § Mitigations. The structural counter-measure against silent lossy translation is preserved via the existing test strategy documented below; the goal (CI-visible lossy-translation coverage) is met; only the mechanism name was wrong.
- **Substitute test strategy (live at v0.1):**
- Entry-surface IR↔OpenAI round-trip — Suite 3 (`irChunkToOpenAISSE format` + `openai-to-ir` tests in `test-features.mjs`) covers the OpenAI ↔ IR direction at the entry surface.
- Per-plugin spawn-with-mock tests in each plugin's test block (anthropic, codex, and mistral spawn unit tests) exercise the IR → CLI args → IR-chunk round-trip end-to-end.
- Lossy-translation edges are documented inline in each plugin's header comment; `mistral.mjs`'s explicit "DOCS-N" UNPINNED markers are the most thorough example; `anthropic.mjs` and `codex.mjs` carry equivalent lossy-field tables in their headers.
- Future plugins MUST include both: (a) a spawn-with-mock test block, and (b) header documentation of any lossy IR fields. These two requirements are the enforcement mechanism replacing the `__irRoundTripTest()` export-name aspiration.
- **Original § Mitigations update:** The `__irRoundTripTest()` sentence in § Mitigations below is replaced with a pointer to this amendment. See § Mitigations.
- **Procedural mechanism:** CC 开发铁律 v1.6 § 10.x (Round-3 Cold Audit caught it as F5).
## Context
OLP exposes exactly one external API surface: OpenAI-compatible `/v1/chat/completions` (per spec §4.1). Internally, OLP fans out to N providers, each with its own native protocol — Anthropic Messages API, OpenAI Chat Completions, Mistral La Plateforme, xAI Grok, Moonshot Kimi, etc. The combinatorial fact is unavoidable: a request entering as OpenAI shape must exit as Anthropic shape (or Mistral shape, or whatever the routing chain advances to), and the response on the way back must travel the inverse path.
The ad-hoc approach is per-provider entry-to-native translation:
```
provider.spawn(openAIRequest) {
const native = openAIToProviderNative(openAIRequest); // per-provider impl
...
const openAIResponse = providerNativeToOpenAI(nativeResponse); // per-provider impl
return openAIResponse;
}
```
At three providers, that's six translation functions (three forward + three reverse). At eight providers it's sixteen. None of them share code — Anthropic-to-OpenAI and Mistral-to-OpenAI have different shape mismatches, different tool-calling translations, different streaming-event mappings. The "OpenAI-shape canonical" assumption privileges one provider's protocol as the lingua franca, which is wrong: OpenAI's Chat Completions is OLP's *entry* surface, not OLP's *internal* representation. Anchoring internal logic on the entry surface means every entry-surface change (a new OpenAI field, a deprecated OpenAI field, a new OpenAI streaming-event variant) ripples through every provider plugin.
The structural answer is an **Intermediate Representation (IR)**: a canonical OLP-internal shape that *all* provider plugins consume (forward) and produce (reverse). The entry adapter translates OpenAI → IR once. Each provider plugin translates IR → native and native → IR. The combinatorial cost goes from N² to 2N: one entry adapter + N provider translators.
The IR is not OpenAI Chat Completions, and it is not Anthropic Messages. It is a *deliberately-OLP-owned* shape, normalized to be expressible in every provider's native shape with documented lossy fields where a provider's expressiveness is narrower than the IR. Choosing IR shape carefully — neither so rich it cannot round-trip through Mistral, nor so narrow it loses Anthropic's tool_use semantics — is the load-bearing design decision of this ADR.
The fallback engine (ADR 0004) is a second consumer of IR's shape stability. When a request fails over from Anthropic to OpenAI, the IR that was constructed at the entry point is replayed against the next provider. If the IR were a thin OpenAI wrapper, replay against OpenAI would be trivial but replay against Mistral or xAI would re-run the same shape-translation pain. A properly-OLP-owned IR makes replay symmetric across providers.
## Decision
OLP defines an Intermediate Representation (IR) as the canonical internal shape between the OpenAI-compat entry surface and each provider plugin. Per spec §4.2 (IR subsection), the IR v1.0 encodes:
**Required fields:**
- `messages[]` — each with `role`, `content`, optional `name`, optional `tool_calls`, optional `tool_call_id`. Roles supported: `system`, `user`, `assistant`, `tool`.
- `model` — the user-requested model string. v0.1: passed verbatim to the provider CLI (`claude -p --model <model>`, `codex exec --model <model>`, etc.); each provider's CLI accepts its own aliases natively per its docs (D17 SPOT decision, commit `cb86807`). *(The original v1.0 text incorrectly stated the plugin maps this to a provider-native identifier — corrected by Amendment 2.)* v1.x may add an explicit OLP-side mapping step if any provider's CLI drops alias support.
- `stream` — boolean. SSE expected on the entry side iff true.
**Optional fields:**
- `max_tokens` — integer; provider plugins clamp to provider-supported maxima.
- `temperature` — float [0, 2]; provider plugins map to provider-native range (e.g., Anthropic uses [0, 1]).
- `top_p` — float [0, 1].
- `stop` — string or string[]; provider plugins handle the single-vs-array shape difference per native protocol.
- `tools[]` + `tool_choice` — function-calling subset. v1.0 supports basic function definitions (`name`, `description`, `parameters` JSON-schema); advanced features (parallel tool use, tool result handling beyond single round-trip) are per-provider documented as caveats per spec §8 Q-C.
- `response_format` — best-effort; provider-specific (e.g., Anthropic does not natively honor a `response_format: json_object` field, so this becomes a system-prompt augmentation on that provider).
**Translation direction model.** Each provider plugin owns two translation functions:
```js
// IR → provider-native shape
const nativeRequest = provider.irToNative(irRequest);
// provider-native response chunk → IR response chunk
const irChunk = provider.nativeToIR(nativeChunk);
```
The entry adapter (`server.mjs`'s `/v1/chat/completions` handler) owns the inverse:
```js
const irRequest = openAIToIR(openAIRequest);
const irResponseStream = await provider.spawn(irRequest, authContext);
for await (const irChunk of irResponseStream) {
res.write(irToOpenAI(irChunk));
}
```
**Lossy-translation documentation requirement.** When a provider plugin's `irToNative` or `nativeToIR` cannot losslessly carry an IR field (e.g., Mistral Vibe does not support `tool_choice: "required"`), the lossy edge is documented in the provider plugin's header comment AND in `docs/provider-caveats.md` (📋 planned — not yet authored; until it exists, lossy translations are recorded only in the provider plugin header comments). The caveats document is referenced from `/v1/models` response metadata so clients can see provider-specific limits without reading source.
**IR is versioned.** This ADR ratifies IR v1.0. Future additions (e.g., a `reasoning_effort` field if reasoning-model behavior diverges enough across providers to warrant explicit modeling) require an amendment ADR. Removals are breaking changes and require a major-version bump of OLP.
**The IR is not exposed externally.** It is OLP-internal. There is no `/v1/ir/*` endpoint, no client SDK in IR shape, no documented external IR contract. The only public contract is OpenAI `/v1/chat/completions`; the IR is an implementation detail that the maintainer (and the maintainer's reviewers) reason about, not a contract with clients.
## Consequences
**Positive**
- Adding a provider is N translation functions (one IR→native, one native→IR), not 2N (one openai→native, one native→openai for every provider). The maintenance burden of "OpenAI shipped a new field" is one entry-adapter change, not one change per provider.
- The fallback engine (ADR 0004) replays the same IR object across providers in a chain. Replay correctness depends on IR being a fixed-shape data structure; there is no per-provider state leaking through that the next chain hop has to re-derive.
- Lossy translations are documented per-provider and surfaced to clients via `docs/provider-caveats.md` (📋 planned) + `/v1/models` metadata. A client choosing a fallback chain can see "this chain's third hop is Mistral, which does not support tool_choice=required" *before* they hit the failure mode.
- The IR is OLP's source of truth for "what was actually requested" for cache-key generation (ADR 0005). Caching on OpenAI shape would couple cache keys to OpenAI field naming; caching on IR makes the cache key provider-agnostic.
**Negative**
- Two translation layers per request: openai→IR at entry, IR→native at provider. That's two passes of object construction per request, plus the inverse on the way back. For family-scale traffic this is unmeasurable; at higher scale the overhead would need profiling.
- IR design decisions are sticky. Choosing the wrong shape for `tool_calls` in v1.0 (e.g., privileging OpenAI's parallel-tool-call shape over Anthropic's single-tool shape) would force every provider plugin to work around the bad choice forever. IR v1.0 is intentionally narrow (basic function calling only) to give the maintainer space to expand the IR with informed amendments rather than commit too early.
- The IR is a third concept (alongside OpenAI shape and per-provider native shape) that every contributor must internalize. Onboarding a new contributor means teaching three shapes, not two. The PR template and OLP ALIGNMENT.md call out the IR explicitly so contributors know which shape applies in which layer of code.
**Mitigations**
- IR v1.0 is deliberately a subset, not a superset, of what providers can do. Where providers diverge in expressive power (tool calling, structured outputs, reasoning modes), the IR encodes the *common subset* in v1.0, with caveats documenting provider extensions. Expanding the IR to cover provider extensions is an explicit amendment, not a silent generalization.
- *(The `__irRoundTripTest()` export-name claim that originally appeared here was removed by Amendment 1 (D31 F5). The structural counter-measure against silent lossy translation is the substitute test strategy described in Amendment 1: entry-surface round-trip tests in Suite 3 of `test-features.mjs` + per-plugin spawn-with-mock test blocks + inline lossy-field header documentation. Future plugins must satisfy both requirements.)*
- The IR has no external surface. If IR v1.0 turns out to be the wrong shape and IR v2.0 is incompatible, the migration is OLP-internal: provider plugins are updated, the entry adapter is updated, no client code changes. The cost of being wrong is bounded by the project's own codebase.
## Alternatives considered
**(a) Full pass-through with no normalization — OpenAI request goes straight to each provider plugin.** Provider plugins each implement their own openai→native + native→openai. Rejected: this is the N² shape the IR exists to avoid. Worse, it makes the fallback engine (ADR 0004) harder, because each fallback hop has to redo openai→native; there is no canonical "what was requested" object to replay. The shape-stability invariant the fallback engine depends on becomes per-provider rather than systemic.
**(b) Per-provider entry endpoints — `/v1/anthropic/messages`, `/v1/openai/chat/completions`, `/v1/mistral/...`.** Each entry endpoint speaks its provider's native shape; no translation. Rejected: this just relocates the N² problem to the client side — each client now needs N integrations (one per provider's wire format), and the unified-endpoint value proposition (spec §1: "one HTTP endpoint, multiple subscriptions behind it") is dead on arrival. Cross-provider fallback also stops being client-transparent: clients have to know about the fallback because they have to switch endpoints when the primary fails.
**(c) Use Anthropic Messages format as the IR (Anthropic-native as canonical).** This privileges Anthropic's shape as OLP's internal language. Rejected on two grounds: (1) Anthropic is currently one provider in eight; privileging its shape codifies provider preference into the IR, which is the wrong abstraction layer; (2) Anthropic-shape is rich enough to be lossy when translated to spartans like Mistral Vibe, so the IR would have features no other provider could use without elaborate fallback in the translator. The IR should be neutral, with provider extensions documented per-plugin, not Anthropic-shaped with per-plugin de-features.
**(d) Use OpenAI Chat Completions as the IR (entry-surface as canonical).** Privileges OpenAI shape. Rejected: this is the implicit shape of alternative (a), just lifted into a named layer. The coupling to OpenAI's evolving spec means every OpenAI deprecation / addition is a cross-cutting change. The IR exists precisely to decouple OLP-internal logic from OpenAI's release cadence.
**(e) Defer IR design and start with two providers tightly coupled (Anthropic + OpenAI), refactor to IR when adding the third.** Rejected: this defers the architectural decision into a code-rewrite-under-pressure moment (adding Mistral while two providers are in production). The pattern of "we'll abstract it later" is what produced the hybrid awkwardness OCP ADR 0005 warned against. Pay the IR cost up front, with v1.0 deliberately narrow, and expand via amendment.
## Sources
- OLP v0.1 spec §4.1 (Single-protocol entry: OpenAI-compatible)
- OLP v0.1 spec §4.2 (Plugin-based provider system, including IR subsection)
- OLP v0.1 spec §8 Q-C (Tool-calling translation differences across providers — informs why IR v1.0 keeps tool calling to a basic subset)
- OCP ADR 0003 (`models.json` SPOT) — informs the "OLP-owned canonical shape, not external surface" framing