chore: bootstrap OLP v0.1 — multi-provider LLM proxy

Initial release. OLP (Open LLM Proxy) is a personal- and family-scale
multi-provider LLM proxy that supersedes OCP (Open Claude Proxy).

Trigger: Anthropic's 2026-05-14 announcement (effective 2026-06-15)
moves `claude -p` / Agent SDK / third-party agent traffic out of the
Pro/Max subscription pool into a separate fixed monthly Agent SDK
Credit pool. OCP's foundational assumption ("subscription = unlimited
within rate limits") breaks for Anthropic on that date. Spreading
risk across multiple providers is the structural response.

Phase 0 lands:
- ALIGNMENT.md (constitution: 5 Rules, 3 Authorities, 4-tier Risk
  Framework, 8-provider inventory)
- AGENTS.md (multi-tool agent guidelines; inherits cc-rules)
- CLAUDE.md (Claude-Code session instructions + release_kit overlay)
- README.md (phase-aware skeleton)
- docs/adr/0001-0006 (Founding ADRs: project founding / plugin
  architecture / IR design / fallback engine / cross-provider cache /
  provider inclusion + risk-tier framework)
- .github/PULL_REQUEST_TEMPLATE.md (8-radio Change Type + per-type
  Authority Evidence + Iron Rule 10 reviewer checklist)
- .github/workflows/alignment.yml (blacklist + Antigravity exclusion
  enforcement + models-registry validator + commit-citation soft check)
- .github/workflows/release.yml (auto-release on tag with version
  match check per Iron Rule 5)
- .github/workflows/test.yml (Node 20/24 matrix, bootstrap-tolerant)
- package.json, .gitignore, LICENSE (MIT), CHANGELOG.md

Provider inventory at bootstrap:
  Tier D (default-enabled):       anthropic, openai, mistral
  Tier C (opt-in):                grok, kimi
  Tier B (opt-in + consent):      minimax, glm, qwen
  Tier A (permanently excluded):  google-antigravity

Supersedes OCP ADR 0005 (No Multi-Provider) per OLP ADR 0001. OCP
will enter maintenance mode when OLP v0.1 ships per Phase 7 plan.

Iron Rule 10 gate: fresh-context independent opus reviewer audited
all 15 governance files against OLP v0.1 spec + OCP precedent.
Verdict: APPROVE_WITH_MINOR. Two minor findings folded in:
  1. alignment.yml heredoc EOF moved to column 0 (was indented;
     bash parse failed silently on real blacklist hits, printing
     a cryptic "syntax error" instead of the structured ALIGNMENT
     GUARDRAIL FAILURE banner).
  2. AGENTS.md clarified that the SPOT discipline for
     models-registry.json will be codified by a Phase-1 ADR (OLP
     ADR 0003 is currently the IR design, not a SPOT codification;
     OCP's ADR 0003 is the precedent but OLP's registry shape
     differs and warrants its own ADR).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-23 15:46:56 +10:00
co-authored by Claude Opus 4.7
commit 0041fb1017
19 changed files with 1776 additions and 0 deletions
+69
View File
@@ -0,0 +1,69 @@
# ADR 0001 — Project Founding: Mission, Non-mission, and Supersession of OCP ADR 0005
- **Date:** 2026-05-23
- **Status:** Accepted (bootstrap)
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:** OLP v0.1 spec §1, §2, §10; OCP ADR 0005 (No Multi-Provider — explicitly superseded by this ADR); Anthropic billing announcement 2026-05-14
## Context
On 2026-05-14, Anthropic announced (effective 2026-06-15) that `claude -p`, the Agent SDK, and third-party agent traffic move out of the Pro/Max subscription pool into a separate fixed monthly Agent SDK Credit pool ($100/month at Max5x, no rollover, then standard per-token API rates). OCP — the Open Claude Proxy — was built on the foundational assumption that *subscription = unlimited within rate limits*. That assumption breaks on 2026-06-15 for the only provider OCP serves.
This is not a `cli.js` drift in the OCP-ADR-0002 sense. It is not an internal authoring failure. The proxy logic remains aligned to `cli.js` byte-for-byte. What changed is the *commercial contract* sitting underneath the proxy: the subscription the maintainer pays for no longer underwrites the traffic OCP forwards. OCP-as-written continues to work; it just stops being the cost-optimal way to spend the maintainer's LLM budget.
OCP ADR 0005 (2026-05-06, "No Multi-Provider") explicitly rejected a multi-provider refactor on four grounds: (1) loss of `cli.js` alignment as moat, (2) hybrid-architecture awkwardness, (3) direct competition with funded incumbents, (4) the grayscale problem isn't solved by adding providers. Eight days after that ADR shipped, the assumption underpinning ground 1 — that there *was* an unlimited subscription pool worth aligning to — was invalidated by Anthropic's announcement. The other three grounds remain partially valid but no longer dominate: (2) hybrid awkwardness goes away if a clean multi-provider proxy is built from scratch rather than retrofitted onto OCP's single-provider core; (3) competition with funded incumbents is irrelevant because the new project, like OCP, is a personal-and-family-scale tool not chasing commercial revenue; (4) grayscale is intrinsic to the spawn-binary architecture regardless of how many providers participate.
The structural response is not to amend OCP ADR 0005 in place — that ADR was correct under its premises, and patching it would muddy the historical record. The structural response is a new project, founded today, that supersedes ADR 0005 by being the codified alternative the ADR explicitly contemplated in its trigger conditions ("[revisit if] a genuine user need emerges … that single-provider OCP cannot serve").
## Decision
Found OLP (Open LLM Proxy) as a new project, separate from OCP, with the following mission and non-mission boundaries:
**Mission.** OLP is a personal- and family-scale multi-provider LLM proxy that maximizes the value of LLM subscriptions the maintainer already pays for, with intelligent fallback when one provider's quota is exhausted and content-addressed caching to minimize quota consumption. Core value proposition: one HTTP endpoint, multiple subscriptions behind it, automatic routing, automatic fallback, transparent caching — so IDE clients and family clients keep working as long as *any* of the subscriptions has quota left.
**Non-mission (explicit).** Per spec §1, OLP is **not**:
- A commercial multi-tenant SaaS. Helicone, OpenRouter, LiteLLM, Portkey, Cloudflare AI Gateway already serve that market with funding, SOC2, dashboards, and team features. OLP enters none of those races.
- A generic enterprise AI gateway competing on provider breadth. The provider set is intentionally narrow (currently 3 default + 2 optional tier-1 + 3 optional tier-2) and curated by subscription economics, not by "more is better."
- A model-capability router. OLP does not auto-route to "the smartest model"; the user picks the model explicitly per chain in `config.routing.chains[]`. Capability routing is a separate problem with separate failure modes and is out of scope for v1.0 and beyond.
- A conversation-state store. Memory and continuity are client-side concerns (Memory Continuity, Hermes equivalents, IDE-side context). OLP is a pure stateless proxy.
**Supersession of OCP ADR 0005.** OCP ADR 0005 ("No Multi-Provider Refactor") is explicitly superseded by OLP's existence. Specifically:
- ADR 0005's premise that *single-provider sufficiency* was the right scope for the maintainer's LLM proxy was correct on 2026-05-06 and is wrong on 2026-05-23, because Anthropic changed the underlying contract.
- ADR 0005's recommendation that "commercial work, if pursued, starts elsewhere" pointed correctly toward "a separate repository, separate name, BYOK from day one, no `cli.js` spawn." OLP is not the commercial work ADR 0005 contemplated, but it is the structural shape ADR 0005 endorsed: a separate repo with multi-provider design baked in from day one.
- OCP itself is not deleted. Per spec §7, OCP enters maintenance mode when OLP v0.1 ships. The two projects do not parallel-run in production (port-3456 conflict, single launchd service slot, one set of credentials per machine).
OCP ADR 0005 receives a header amendment on merge of this ADR: *"Superseded by OLP — see [OLP repo URL] ADR 0001."* The body of ADR 0005 is otherwise untouched. Future readers should see the original reasoning intact and the supersession marker explicit.
## Consequences
**Positive**
- Multi-provider routing solves the post-2026-06-15 quota problem that OCP cannot solve on its own. As long as any one of the configured providers has quota remaining, the maintainer's IDE clients keep working.
- Clean-slate codebase avoids the hybrid-architecture awkwardness ADR 0005 correctly warned about. OLP is multi-provider from day one; there is no single-provider core to retrofit.
- Project scope stays bounded by the non-mission list. Future contributors (including the maintainer) reading this ADR see the explicit "we don't do X" list, which makes scope creep cost an ADR amendment rather than a quiet PR.
**Negative**
- OCP's `cli.js`-alignment moat (ADR 0002 + 0005) does not transfer. OLP proxies multiple CLIs, each with its own protocol authority; the `cli.js` byte-for-byte discipline becomes per-provider per-CLI discipline (see ADR 0002 and the new OLP ALIGNMENT.md). This is more surface area to keep honest, not less.
- Migration cost for the user: stop OCP, install OLP, point clients at the new port, migrate auth tokens. Spec §7 budgets this at <5 minutes via `scripts/migrate-from-ocp.mjs`. Real-world time will be higher on the first machine.
- The maintainer now operates two repos during OLP's pre-v1.0 phase: OCP in maintenance, OLP under active development. This is intentional — OCP serves real traffic today and cannot be deleted in advance — but doubles the release surface temporarily.
**Mitigations**
- OCP enters maintenance, not parallel development. The maintainer is not maintaining two evolving codebases; OCP receives only stability fixes after OLP v0.1 ships.
- The non-mission list in this ADR is intentionally specific (commercial SaaS, breadth race, capability routing, conversation state) so "creep into one of these four" requires an explicit ADR amendment.
- Spec §10 (Decision log) records the 2026-05-23 founding decisions; new founding-era decisions are appended to spec §10 with cross-references to the relevant ADR.
## Alternatives considered
**(a) Amend OCP ADR 0005 in place and refactor OCP to multi-provider.** Considered and rejected. ADR 0005 explicitly warned against hybrid architecture awkwardness for exactly this case; the maintainer recorded the warning in writing eight days ago. Honoring that warning means starting clean, not retrofitting. The structural cost of two repos during the transition is lower than the structural cost of muddying the constitutional reasoning that protected OCP from earlier scope creep.
**(b) Wait until 2026-06-15 (effective date) before founding OLP.** Considered and rejected. The Anthropic announcement is dated, the effective date is dated, and the construction time for OLP v0.1 (~16 working days per spec §6 phase plan) puts a 2026-05-23 start at roughly the effective date — exactly when the maintainer's family clients begin seeing the new credit-pool consumption. Waiting until the wall clock turns means OLP ships *after* the problem starts hurting.
**(c) Add multi-provider behind a feature flag in OCP and ship it as OCP v4.0.** Considered and rejected. Feature flags do not retire the single-provider code path; they double the test matrix and create the exact hybrid path ADR 0005 warned about. The honest engineering move is a separate codebase.
**(d) Drop the proxy approach entirely and let IDE clients talk to each provider directly.** Considered and rejected. The whole point of OCP/OLP is *one* endpoint that family/IDE clients can be pointed at. N clients × M providers = N×M configuration surface managed by the maintainer, plus zero fallback, zero cross-provider caching, zero unified spend dashboard. The proxy is load-bearing.
## Sources
- OLP v0.1 spec §1 (Mission), §2 (Why OLP exists), §7 (Migration from OCP), §10 (Decision log)
- OCP ADR 0005 (2026-05-06) — `~/ocp/docs/adr/0005-no-multi-provider.md`
- Anthropic billing announcement 2026-05-14 — `~/.cc-rules/memory/learnings/anthropic_claude_code_billing_split_2026_06_15.md`
- Memory entry 2026-05-07 (Mac → OCP v3.13.0 cache layer hardening) — records the maintainer's preceding decision that OCP itself will not be commercialized
+101
View File
@@ -0,0 +1,101 @@
# ADR 0002 — Plugin Architecture for Providers
- **Date:** 2026-05-23
- **Status:** Accepted (bootstrap)
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:** OLP v0.1 spec §4.2; ADR 0001 (project founding); ADR 0003 (IR design); ADR 0006 (provider inclusion framework)
## Context
OLP supports a curated set of providers — three default-enabled (Anthropic, OpenAI Codex, Mistral Vibe) plus two optional tier-1 (xAI Grok, Moonshot Kimi) and three optional tier-2 (MiniMax, Zhipu GLM, Alibaba Qwen). Each provider has its own CLI binary, its own auth artifact location, its own request shape, its own response shape, its own quota-reporting endpoint (or none), and its own rate-limit posture. The maintainer's strong prior is that this set grows over the project's lifetime — provider economics will continue to shift, and "the right five providers" in 2027 will not be identical to today's five.
The naive architecture is a monolithic dispatcher inside `server.mjs`:
```js
if (provider === 'anthropic') { spawn claude -p ... }
else if (provider === 'openai') { spawn codex exec --json ... }
else if (provider === 'mistral') { spawn vibe --prompt ... }
// ... and so on for every provider, every auth shape, every quirk
```
This shape works for two providers, becomes painful at four, and is the structural shape that produced the worst pages of OCP's `server.mjs` (1667 lines, ADR 0005 context paragraph). Worse, it makes the answer to "how does a contributor add a sixth provider?" be "edit eight places inside `server.mjs` and hope you caught them all." That is the exact failure mode `models.json` SPOT (OCP ADR 0003) was designed to prevent for model metadata; the provider equivalent needs the same structural answer.
The other end of the spectrum is full external plugin discovery — npm-installed plugins, runtime registration, hot-load. That is unambiguously out of scope for v1.0: the provider set is curated for security and ToS-risk reasons (see ADR 0006), and "anyone can install a third-party plugin" violates that curation by design.
The middle path is a **plugin model with a fixed in-tree provider registry**: each provider is a `.mjs` file under `lib/providers/`, all conforming to a single `Provider` contract, loaded at startup from a static enumeration in `lib/providers/index.mjs`. Adding a provider means writing one file and adding one line to the registry. Disabling an optional provider means a config-file toggle, not a code change.
## Decision
Per spec §4.2, OLP uses a plugin-based provider architecture with the following structure:
**Filesystem layout:**
```
lib/providers/
base.mjs # abstract Provider contract + shared helpers
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`
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
glm.mjs # tier-2 optional, default-disabled
qwen.mjs # tier-2 optional, default-disabled
```
**Provider contract** (v1.0 interface — exact shape per spec §4.2):
Every provider plugin exports an object conforming to:
- `name: string` — unique key (`anthropic`, `openai`, `mistral`, etc.)
- `displayName: string` — human-readable name for dashboards and consent UX
- `models: string[]` — models this provider serves
- `auth: { type, storage, path, refresh }` — auth-artifact profile
- `spawn: async (normalizedRequest, authContext) => AsyncIterator<ResponseChunk>` — the core invocation
- `estimateCost: (request) => { inputTokens, outputTokensEstimate, currency, usd }` — best-effort, may return null
- `quotaStatus: async (authContext) => { available, percentUsed, resetsAt, pool }` — best-effort, null if unretrievable
- `healthCheck: async () => { ok, latencyMs, error? }` — startup and `/health` endpoint use this
- `hints: { requiresTTY, concurrentSpawnSafe, maxConcurrent }` — fingerprint and concurrency hints
**Loading model.** `lib/providers/index.mjs` is a hand-maintained static enumeration. There is no filesystem scan, no `require.context`, no dynamic discovery. Adding a provider requires:
1. Write `lib/providers/<name>.mjs` conforming to the contract.
2. Add one import + one entry to `lib/providers/index.mjs`.
3. Add a row to README's "Supported Providers" table.
4. File an inclusion ADR per ADR 0006's framework.
**Disable model.** Optional providers (tier-1 and tier-2 per ADR 0006) are present in the registry but `enabled: false` by default. Enable is a `~/.olp/config.json` toggle, plus the tier-2 consent flow described in spec §3.1. Disabling a provider does not require touching `server.mjs`.
**Boundary with `server.mjs`.** `server.mjs` knows about the registry and the contract; it does not know about specific providers. The fallback engine (ADR 0004), the cache layer (ADR 0005), and the dashboard (spec §4.6) all consume providers through the contract, not through provider-specific code paths.
## Consequences
**Positive**
- Adding a new provider is a four-step recipe with no `server.mjs` edits required. The recipe is explicit (file + registry + README + ADR), so a future contributor (including a future Claude session) cannot accidentally do steps 12 without 34.
- The contract is the test surface. A provider plugin can be tested in isolation against a contract conformance suite (`test-features.mjs` extended per spec §6 Phase 1), independent of `server.mjs`.
- `server.mjs` stays generic. There is no "is this Anthropic? then do special thing" path inside the core proxy loop. Provider-specific quirks live inside the provider plugin where they belong.
- Disabling a misbehaving provider (e.g., a ToS change announcement triggers fast-disable per spec §9 risks) is a config flip, not a code revert. The provider quarantine path spec §9 calls for is the existing config mechanism.
**Negative**
- The contract surface is real governance work. Adding a field to the contract (e.g., a new `streamingMode` or `toolUseShape`) is an ADR amendment per OLP ALIGNMENT.md, not a quick PR. This is intentional — contract drift is the path back to the monolithic-dispatcher problem the contract was built to prevent.
- Provider plugins have non-trivial duplication: every provider re-implements the same SSE-chunk-translation skeleton, the same auth-env-injection, the same spawn-with-timeout. `base.mjs` exists to absorb the truly-shared parts, but resisting the temptation to push provider-specific logic into `base.mjs` requires discipline.
- Some providers (notably Anthropic) have *much* more behavior to encode than others (Mistral Vibe is comparatively spartan). The contract has to be expressive enough for the rich case without being burdensome for the spartan case. Lossy translations are documented per-provider per ADR 0003.
**Mitigations**
- `base.mjs` provides shared helpers but does not implement the `Provider` contract itself. Provider plugins compose helpers; they do not inherit from a base class. This keeps the "what does this provider do?" question answerable by reading one file.
- The contract is versioned. v1.0 is the subset in this ADR; future additions (e.g., a `cancel()` method for in-flight request termination, or a `costPerToken` snapshot) require ADR amendment plus a contract-version bump. Old provider plugins continue to declare `contractVersion: '1.0'` and the loader handles the version gap.
- The provider inclusion ADR per ADR 0006 doubles as the contract-conformance review gate. A new provider's inclusion ADR must show how it satisfies each field of the contract; that review is the structural counter-measure against contract drift.
## Alternatives considered
**(a) Monolithic dispatch inside `server.mjs`.** A single function with `if/else if` per provider, each branch implementing spawn/quota/health inline. Rejected: this is the architectural shape that produced OCP's `server.mjs` length problem at *one* provider, and it does not survive contact with three default + two optional tier-1 + three optional tier-2 providers (eight code paths). Worse, it makes provider-disable a code change, which means fast-quarantine in response to a ToS announcement (spec §9) is a release event rather than a config flip.
**(b) Full external plugin discovery (npm-installable, runtime-loaded, hot-discoverable).** Plugins are npm packages; OLP scans `node_modules/@olp-providers/*` at startup; users `npm install` to add a provider. Rejected for v1.0 on three grounds: (1) the provider set is curated for ToS-risk reasons (ADR 0006), and "anyone can install any provider" defeats that curation; (2) the discovery layer is itself non-trivial code (manifest validation, version compatibility, security review of third-party plugin code) that does not earn its complexity at three to eight providers; (3) the contract has not stabilized enough — locking it as a stable plugin API before v1.0 ships is premature commitment.
**(c) Per-provider sub-processes / microservices.** Each provider runs as a separate Node.js process; `server.mjs` is a router that proxies to the right sub-process. Rejected as massive over-engineering for v1.0 traffic levels (family-scale, dozens of requests per hour, not hundreds per second). The spawn-per-request cost is already the dominant latency; sub-process IPC adds latency without buying anything until the maintainer is running OLP at a scale where one Node process is the bottleneck — which is not v1.0's problem.
**(d) Code generation from a YAML manifest per provider.** Each provider is described in YAML; a generator emits the corresponding `.mjs`. Rejected as a layer that does not pay for itself. The provider plugins are ~150400 lines of hand-written code each; generating them from YAML would shift complexity from the `.mjs` to the YAML + generator + the round-trip when a generated file needs to be hand-tuned for a quirk. The generator-first approach also makes the `cli.js`-alignment equivalent (per-provider CLI behavior alignment per OLP ALIGNMENT.md) harder to enforce, because the source of truth becomes the YAML, not the spawned-binary's real behavior.
## Sources
- OLP v0.1 spec §4.2 (Plugin-based provider system, including the v1.0 Provider contract definition)
- OCP ADR 0003 (`models.json` as SPOT) — informs the "static enumeration, not filesystem scan" loading model
- OCP ADR 0005 — the context paragraph references OCP's `server.mjs` reaching 1667 lines at one provider; the plugin architecture is the structural response to that complexity scaling N×
@@ -0,0 +1,109 @@
# 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)
## 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. The provider plugin maps this to the provider-native model identifier (e.g., `claude-sonnet-4-6``claude-sonnet-4-6-20260301` for Anthropic).
- `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`. 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` + `/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.
- Each provider plugin includes a `__irRoundTripTest()` export that exercises the plugin's `irToNative` + `nativeToIR` pair against a fixture set in `test-features.mjs`. Round-trip discrepancies fail CI. This is the structural counter-measure against the silent-lossy-translation failure mode.
- 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
+108
View File
@@ -0,0 +1,108 @@
# ADR 0004 — Fallback Engine Semantics and Safety
- **Date:** 2026-05-23
- **Status:** Accepted (bootstrap)
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:** OLP v0.1 spec §4.3; ADR 0001 (project founding — fallback is OLP's core value proposition); ADR 0002 (plugin architecture); ADR 0003 (IR — fallback replays IR across chain hops)
## Context
A multi-provider proxy is only valuable if it fails over gracefully. Per spec §1, OLP's core value proposition is "your IDEs and family clients keep working as long as *any* of your subscriptions has quota left." If Anthropic returns 529 (overloaded), or OpenAI returns 429 with `insufficient_quota`, or `claude -p` exits non-zero, the client should not see an error; the client should see the next provider in the chain transparently take over.
Naive fallback is dangerous. The most-natural-looking implementation — "any error, retry next provider" — is catastrophic for any request with side effects. Consider a `/v1/chat/completions` request whose first response chunk contained `{"role": "assistant", "tool_calls": [{"name": "write_file", "arguments": "..."}]}`, and the client's agent loop has already begun executing that tool call when the SSE stream breaks. If OLP retries against the next provider, the second provider's response may contain a *different* tool call — and now the client agent has executed two different write_file operations from what looks to them like one request. This is exactly the "duplicate write" failure mode that makes naive retry a foot-gun in any HTTP system with non-idempotent operations.
The structural answer is **fallback only on idempotent failures**: if the response has not started streaming back to the client, fallback is safe. If the response has started, the request is not retried — the client sees the truncation and decides what to do (an IDE agent loop typically has its own retry logic with awareness of partial state; OLP cannot reason about that).
The second axis of fallback design is **trigger taxonomy**. Not every provider response is a fallback signal. Anthropic 401 (auth failure) is a fallback signal only if it persistently fails — otherwise it's a configuration problem the user needs to fix, not a quota problem the proxy should hide. Anthropic 429 with no body might be a transient burst limit; OpenAI 429 with `insufficient_quota` is a hard out-of-credit signal. The fallback engine has to distinguish these.
The third axis is **chain advancement**. Spec §4.3 calls for one-provider-at-a-time advancement: if the chain is `[anthropic, openai, mistral]` and Anthropic fails, advance to OpenAI; if OpenAI also fails, advance to Mistral; if Mistral also fails, return the *original* error (not Mistral's, not the cascade) with `X-OLP-Fallback-Exhausted` listing tried providers. This preserves the client's ability to debug — the first failure is the load-bearing signal, not the last one.
The fourth axis is **observability**. Every fallback hop must be loggable and counted. `X-OLP-Fallback-Hops: 0` on a successful primary serve; `X-OLP-Fallback-Hops: 1` on first-fallback success; `X-OLP-Provider-Used: openai` so the client knows which subscription consumed the quota. These headers are user-facing instrumentation, not internal-debug; they are spec'd in §4.7.
## Decision
Per spec §4.3, the OLP fallback engine implements the following:
**Trigger taxonomy.**
- **Hard triggers (mandatory, non-configurable).**
- HTTP 5xx from provider's underlying API (after spawned CLI surfaces them)
- HTTP 4xx that semantically indicate quota exhaustion (Anthropic 529 overloaded, OpenAI 429 with `insufficient_quota` body, Anthropic post-2026-06-15 credit-pool-exhausted indicator if a body discriminator exists)
- Provider CLI exit code ≠ 0 with no usable response chunks streamed
- Provider CLI spawn timeout (configurable per-provider via `hints.maxSpawnTimeMs`)
- **Soft triggers (configurable per chain).**
- `credit_pool_percent_threshold` — fall back before hitting Anthropic's 100% Agent SDK Credit consumption, e.g., at 90% (per spec §4.3 example config)
- `daily_request_count_threshold` — fall back after N requests on the primary today
- `five_hour_window_percent_threshold` — fall back based on rolling 5h window quota usage
- Soft triggers are evaluated *before* spawn. If a soft trigger fires for the primary, the proxy advances to the next chain entry without attempting the primary at all.
- **Deterministic triggers (deferred to v1.x).** Time-of-day routing, request-content routing ("code requests prefer Codex"). Out of scope for v0.1; tracked in spec §8 future work.
- **Cost-aware triggers (deferred to v1.x).** Per spec §4.3 + §8, fully cost-aware automatic fallback is deferred until provider cost-reporting is reliably retrievable. v0.1 ships without this.
**Fallback safety — first-chunk rule.** A request is eligible for fallback **only** if no response chunk has been emitted to the client. Specifically:
```js
if (responseChunksEmitted === 0 && triggerMatches(error)) {
advanceChainAndRetry();
} else {
surfaceErrorToClient(); // client sees truncation; client decides
}
```
This is non-negotiable for v1.0. Post-first-chunk truncations are surfaced to the client with the same error semantics OpenAI uses for streaming interruptions. Tool-using clients (agent loops) typically have their own handling for partial responses; OLP cannot second-guess that handling.
**Chain advancement — one at a time.** Given a chain `[A, B, C]`:
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).
**Observability headers (per spec §4.7).**
- `X-OLP-Provider-Used: <provider-name>`
- `X-OLP-Model-Used: <provider-native-model-id>`
- `X-OLP-Fallback-Hops: <integer ≥ 0>`
- `X-OLP-Cache: hit | miss | bypass`
- `X-OLP-Latency-Ms: <integer>`
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.
**No fallback for client-side errors.** HTTP 400, 401 (auth misconfigured), 403, 404, 422 from a provider are *not* fallback triggers. They indicate the request itself is malformed or the user's auth is wrong, and silently masking that by trying the next provider would prevent the user from ever discovering the misconfiguration. These errors are surfaced to the client immediately with provider attribution.
## Consequences
**Positive**
- The first-chunk rule eliminates the duplicate-side-effect failure mode at the architectural level. There is no configuration knob, no per-provider override, no "we'll try to be smart about it" — the rule is binary and the safety guarantee is binary.
- The trigger taxonomy is enumerated and bounded. A reviewer reading a PR that proposes a new trigger has a clean checkpoint: "is this hard, soft, deterministic, or cost-aware? cite the spec §4.3 category." Triggers outside the four categories require an ADR amendment.
- Chain advancement returning the *original* error (not the cascade) makes debugging tractable. The user sees what their primary provider said, not a confusing tail-end "Mistral failed" message that obscures the actual problem.
- Observability headers are surface-level. Clients (and the maintainer running `curl -i`) can immediately see which provider served a request and whether fallback fired, without enabling debug logging.
**Negative**
- Some failures *don't* fall back. Post-first-chunk truncations surface to the client as truncations. This is the correct behavior, but it means OLP cannot achieve "literally never fails as long as one provider is alive" — clients still see truncations when a provider dies mid-response. This is a property of the safety guarantee, not a bug.
- The credit-pool-percent-threshold soft trigger requires per-provider quota retrievability. Per spec §4.2, `quotaStatus` may return null if a provider doesn't expose it. Soft triggers gracefully degrade (treat null as "don't fire") but the value proposition is weaker for providers without retrievable quota.
- The trigger configuration is per-chain, not global. A user with three chains has three places to tune thresholds. The dashboard surface (spec §4.6) makes this navigable; raw `config.json` editing is the source of truth.
**Mitigations**
- The first-chunk rule applies at the byte level (any data written to the response stream blocks fallback), not at the semantic level (where it would require parsing). Implementation is a single boolean flag tracking whether anything has been written; the rule is auditable in a few lines of code.
- Provider plugins that cannot retrieve quota (`quotaStatus` returns null) are documented in their plugin header and in `docs/provider-caveats.md`. Soft triggers configured against such providers issue a startup warning so the user knows the trigger will never fire.
- The deferred trigger types (deterministic, cost-aware) are tracked in spec §8 with explicit Q-tags. Adding them later is an ADR amendment plus a contract addition to the trigger taxonomy, not a redesign.
## Alternatives considered
**(a) Full retry-on-any-error.** Any non-2xx response triggers fallback. Rejected: this is the duplicate-side-effect foot-gun, and it also masks client-side errors (a malformed request would silently parade through every provider in the chain before being surfaced). The safety guarantee OLP is making would be impossible to make.
**(b) No fallback at all — pure routing only.** OLP picks one provider per request based on chain config but does not retry on failure. Rejected: this defeats the spec §1 value proposition. The whole reason for OLP's existence is "as long as *any* subscription has quota left, things work." No-fallback gives the user a manually-coordinated multi-provider setup, which they could have built with environment-variable swapping in their IDE.
**(c) Semantic retry — try to detect when retry is safe.** Inspect the in-flight request, the partial response, and the failure mode; if the partial response is reasoning-only (no tool calls emitted yet), retry; if tool calls have been emitted, do not retry. Rejected for v1.0 as too ambitious. Distinguishing "safe to retry" requires parsing the partial IR response, identifying side-effecting tool calls (which means OLP has to know which tools are side-effecting — it cannot), and reasoning about idempotency at the application layer. v1.0 ships with the conservative first-chunk rule; semantic retry is a candidate for a future ADR if real failure modes justify the complexity.
**(d) Retry-after-N-seconds before fallback.** When a provider transiently fails (e.g., Anthropic 529), wait N seconds and retry the same provider before advancing the chain. Rejected as a separate concern from this ADR. Per-provider retry-with-backoff before chain advancement is a useful refinement, but it's an enhancement to the "try A" step of chain advancement, not a change to the chain advancement rules. Tracked as future work; v1.0 has a single attempt per chain hop.
**(e) Parallel fan-out — issue requests to all providers in the chain simultaneously, race them.** Rejected: this consumes quota on all providers regardless of which one's response is used, which directly violates the spec §1 value proposition (minimize quota consumption). Quota is the constraint OLP exists to manage; spending it speculatively is the wrong shape.
## Sources
- OLP v0.1 spec §4.3 (Fallback engine, including trigger types, fallback safety, example config)
- OLP v0.1 spec §4.7 (Observability — response headers)
- OLP v0.1 spec §8 Q-B, Q-E (Open questions about provider-specific quota and rate-limit-window awareness)
- OCP architecture context (`~/ocp/server.mjs` does no fallback — it forwards to one Anthropic endpoint; the absence of fallback handling in OCP is informative about what greenfield design OLP needs)
+104
View File
@@ -0,0 +1,104 @@
# ADR 0005 — Cache Layer Cross-Provider Design
- **Date:** 2026-05-23
- **Status:** Accepted (bootstrap)
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:** OLP v0.1 spec §4.4; ADR 0002 (plugin architecture); ADR 0003 (IR — cache keys are computed over IR shape); ADR 0004 (fallback — cross-provider cache misses are correct on fallback)
## Context
OCP shipped a four-layer cache hardening (D1+D2+D3+D4) in v3.13.0 (2026-05-07 per the MEMORY.md entry). The four layers:
- **D1 — per-key isolation:** each OCP API key has its own cache namespace, so one user's cache doesn't leak to another's.
- **D2 — `cache_control` bypass:** Anthropic prompt-caching markers in the request bypass OLP's response cache (the prompt cache lives at Anthropic's side; double-caching would shadow Anthropic's TTLs).
- **D3 — chunked stream replay:** SSE streams are replayed from cache without re-spawning the CLI, preserving the chunking pattern the client expects.
- **D4 — singleflight:** concurrent identical requests share one spawn; all callers receive the same response.
All four are valuable in OLP. None of the four translate directly because OCP's cache key was Anthropic-specific in composition:
```
ocp_cache_key = sha256({ messages, tools, temperature, response_format, cache_control })
```
OCP never had a `provider` field in the key because there was only one provider. OLP serves multiple providers, and multiple models per provider. A cache hit between `anthropic/claude-sonnet-4-6` and `openai/gpt-5-codex` would be catastrophic: identical prompts produce *different* outputs on different models, and even on the same model from different providers the response style and tool-calling conventions diverge enough that a cross-provider cache hit is just a wrong answer wearing a correct cache key.
The simple fix is to include `provider` and `model` in the cache key. That's structurally correct but has a non-obvious consequence: when a fallback chain advances from `anthropic/sonnet` to `openai/codex`, the cache lookup against the new provider misses — even if the *prompt* is identical to a previous successful Anthropic serve. This is the right behavior (different model = different output = different cache entry), but it's worth naming explicitly so future readers don't try to "fix" the cache to share entries across providers.
A second design point: OLP's cache key is computed over the **IR** (per ADR 0003), not over the raw OpenAI request shape. This matters because OpenAI shape evolves (new fields, deprecated fields) and we don't want every entry-surface change to invalidate every cache entry. IR is OLP-owned and changes only via amendment ADR, so cache-key stability is governed by OLP's own release cadence, not OpenAI's.
The third design point: `cache_control` (D2) is Anthropic-specific in v1.0. Other providers may grow their own prompt-cache markers over time (OpenAI has prompt caching but the marker semantics differ; Mistral has none as of 2026-05). Per spec §4.4, OLP honors Anthropic `cache_control` markers as bypass signals when the routing target is Anthropic; for non-Anthropic targets, the bypass markers are noop'd (logged once per request at debug level so users can see they were ignored). Future providers' prompt-cache markers extend the bypass logic per-provider; the bypass mechanism is provider-pluggable, not Anthropic-specific.
## Decision
Per spec §4.4, OLP's cache layer:
**Cache key composition (v1.0).**
```
key = sha256(JSON.stringify({
provider, // e.g., 'anthropic', 'openai', 'mistral'
model, // the actual model that served the request (provider-native form)
messages, // IR messages[] — normalized form, not OpenAI raw
tools, // IR tools[] if present, null otherwise
temperature, // included if non-default
response_format, // included if present
cache_control, // Anthropic prompt-caching markers (the markers themselves; the bypass logic is separate)
}))
```
**Per-model isolation.** Cache entries are keyed on `(provider, model)` pair. `anthropic/claude-sonnet-4-6` and `openai/gpt-5-codex` produce distinct cache entries for identical IR messages. There is no cross-model cache sharing in v1.0; this is intentional and correct.
**D1 — per-key isolation (ported from OCP v3.13.0).** Each OLP API key has an independent cache namespace. Cache directory structure:
```
~/.olp/cache/<olp-key-id>/<hash-prefix>/<hash>.json
```
The `<olp-key-id>` segment ensures per-key isolation; the `<hash-prefix>` is the first two hex chars of the key for filesystem-fanout sanity at high cache counts.
**D2 — `cache_control` bypass (ported, scope-expanded).** If the IR request contains Anthropic `cache_control` markers AND the active provider in the current chain hop is Anthropic, the OLP response cache is bypassed (Anthropic's own prompt cache is consulted at the provider). If the active provider is not Anthropic, the `cache_control` markers are stripped from the IR before provider translation (so they don't get passed to providers that wouldn't understand them) and a debug log entry is emitted. Future provider-specific bypass markers extend this rule by adding their own provider-conditional bypass logic.
**D3 — chunked stream replay (ported).** When a cache hit occurs on a `stream: true` request, the cached response is replayed as SSE chunks with the same chunking granularity as the original spawn. This requires storing the chunk boundaries (not just the concatenated content) in the cache entry. Cache entry format includes a `chunks[]` array, each with `delta` and `timestamp` relative to spawn start; replay throttles to the original timing pattern (configurable: real-time replay vs. burst-replay; default burst-replay because real-time replay adds latency without value for cached requests).
**D4 — singleflight (ported).** Concurrent requests with identical cache keys share one spawn. The first request triggers the spawn; subsequent identical requests block on a per-key promise until the first completes, then all read the same response. Per the OCP MEMORY.md learning (`learnings/concurrency_dedup_test_signals.md`), singleflight is verified by observing that N concurrent requests return within milliseconds of each other when the cache key matches.
**Cross-provider fallback cache behavior (new for OLP).** When a request falls back from `anthropic/sonnet` to `openai/codex`, the cache lookup against the new `(openai, codex)` key likely misses. This is correct: even if Anthropic had served the same prompt successfully yesterday, the codex response is a different output and warrants its own cache entry. There is no "cache hit on any provider's prior serve" mode; that would defeat the per-model isolation invariant.
**Cache write conditions.** A response is cached if and only if:
1. The response completed successfully (no truncation, no error mid-stream).
2. The request did not include `cache_control` bypass markers for the active provider.
3. The provider's `hints.cacheable` flag is not `false` (a provider plugin can opt out of caching entirely for, e.g., real-time use cases — none do in v1.0).
4. The response is below a size cap (default 10 MB; configurable). Cache is for hot-path repeat requests, not bulk archive.
## Consequences
**Positive**
- Cross-model contamination is impossible. The `(provider, model)` prefix on every cache key means `claude/sonnet` and `codex/gpt-5` cannot collide even on identical prompts. The cache invariant is auditable in one line of code.
- All four OCP cache hardening layers (D1+D2+D3+D4) port to OLP, preserving the operational properties OCP achieved in v3.13.0. The OCP MEMORY.md learnings about cache-related pitfalls (e.g., not hashing the randomUUID-included full JSON; using content-only sha256) carry over.
- Fallback to a different provider correctly misses cache. The user's quota is consumed on the new provider, but the new provider's response is now cached for future identical requests on the new (provider, model) pair.
- IR-based cache keys (per ADR 0003) decouple cache stability from OpenAI's entry-surface evolution. A new OpenAI field that doesn't change semantics (e.g., a cosmetic field rename) does not invalidate existing cache entries.
**Negative**
- Cache hit rate is lower than OCP's single-provider rate by construction. Per-model isolation means the cache is partitioned N-ways across N (provider, model) pairs. For the maintainer's typical usage (~70% claude, ~25% codex, ~5% other), the most-used pair retains high hit rate; less-used pairs have effectively cold caches. This is the right behavior, not a bug.
- The D3 chunked-replay format adds complexity to cache entries (storing chunk boundaries, not just concatenated content). Cache entries are larger than they would be with naive content storage. Disk usage scales with chunks count; for typical claude responses this is modest, but it is real.
- `cache_control` markers being silently stripped for non-Anthropic providers is a subtle behavior. Users who configure aggressive Anthropic prompt caching and then fall over to OpenAI will get full-cost OpenAI responses without prompt caching, even on prompts they marked as cacheable. The debug log entry mitigates surprise; the README's caveats section names this explicitly.
**Mitigations**
- Cache hit rate is monitored via `/cache/stats` (per spec §4.6), broken down by `(provider, model)` pair. Low hit rates on specific pairs are visible to the maintainer and can inform chain configuration (e.g., "this pair has 2% hit rate; is it worth being in this chain?").
- Cache entry size cap (default 10 MB) prevents pathological growth from the chunked-replay format. Sizes above cap are not cached; logged once.
- The non-Anthropic-provider behavior for `cache_control` markers is tested in `test-features.mjs` to verify the bypass is correctly noop'd, the markers are stripped before provider translation, and the debug log is emitted. The marker-strip behavior is the structural counter-measure against accidentally passing Anthropic-specific markers to providers that wouldn't understand them.
## Alternatives considered
**(a) Model-agnostic caching (cache key includes provider but not model).** A cache hit on `anthropic/claude-sonnet-4-6` for a prompt could serve a request for `anthropic/claude-opus-4-7` if the prompt is identical. Rejected: this is cross-model contamination, and the outputs are simply different. The cache becomes a source of wrong answers.
**(b) Cache disable on fallback paths.** Skip cache lookup entirely for any request that's reached fallback hop ≥ 1. Rejected: this would mean every fallback serve is a fresh spawn even if the new provider has served the same prompt before. Per-model isolation already gives the right behavior here (the fallback hop's cache lookup is against the new (provider, model) and is a miss only if that pair hasn't served before).
**(c) Cross-provider canonicalization — cache a "model-tier" key (e.g., 'sonnet-equivalent') rather than (provider, model).** Anthropic/sonnet, openai/codex, and mistral/devstral could all share a cache entry tagged "sonnet-equivalent". Rejected on two grounds: (1) cross-model output equivalence is false — the outputs *differ*, even if the inputs are equivalent; (2) defining "sonnet-equivalent" is a capability-routing problem (spec §1 non-mission explicitly excludes capability routing). The cache should reflect real outputs, not assumed equivalence classes.
**(d) Don't port D3 (chunked stream replay) — replay cache hits as a single chunk.** Simplifies cache entries; loses the chunking-pattern fidelity OCP achieved in v3.13.0. Rejected: some clients (notably OpenClaw and Continue) make UX decisions based on chunking pattern (e.g., "is this thinking, or is this output?"); collapsing to a single chunk breaks those decisions even though the content is correct. D3's complexity earns its keep.
**(e) Defer caching to a future ADR — ship v1.0 without cache.** Rejected: the OCP cache layer is one of the most successful structural decisions in OCP's history, and the post-2026-06-15 cost picture makes caching *more* valuable, not less (every cache hit is a credit not consumed). Caching is in scope for v1.0.
## Sources
- OLP v0.1 spec §4.4 (Cache layer — inherited from OCP, generalized)
- OCP v3.13.0 release context — MEMORY.md entry 2026-05-07 (Mac → OCP v3.13.0 cache layer hardening shipped)
- OCP `learnings/concurrency_dedup_test_signals.md` — informs D4 (singleflight) test methodology
- OCP `keys.mjs` `cacheHash` / `getCachedResponse` / `setCachedResponse` / `clearCache` — the implementation OLP ports
+113
View File
@@ -0,0 +1,113 @@
# ADR 0006 — Provider Inclusion / Exclusion Criteria and Risk-Tier Framework
- **Date:** 2026-05-23
- **Status:** Accepted (bootstrap)
- **Authors:** project maintainer (with AI drafting assistance)
- **Related:** OLP v0.1 spec §3.1, §10; ADR 0001 (project founding); ADR 0002 (plugin architecture — inclusion criteria gate plugin merges); OCP ADR 0006 (OpenAI Shim Scope — informed the risk-tier framing approach)
## Context
OLP's value proposition (spec §1) is multi-provider spreading of LLM subscription quota. The more providers OLP supports, the better its fallback fan-out and the more resilient the overall system. But provider inclusion is not free: each provider's terms of service, enforcement history, and cost-benefit profile differ, and including a provider with poor ToS posture exposes the user to account-revocation or service-termination risk that the user may not have signed up for when they enabled OLP.
The decision space is not binary "include / exclude." A four-tier framework better matches the actual risk landscape observed in the 2026-05-23 provider deep-dive audit:
- Some providers (OpenAI Codex, Mistral Vibe, pre-2026-06-15 Anthropic) have permissive ToS with no anti-third-party clauses and healthy ecosystems. These are safe defaults.
- Some providers (xAI Grok, Moonshot Kimi) have *tightened* ToS language recently but no documented enforcement history. The user may reasonably opt in with awareness.
- Some providers (MiniMax, Zhipu GLM, Alibaba Qwen) have explicit anti-automation clauses in their subscription plan terms. Key revocation is a documented risk; account-wide enforcement is not.
- One provider (Google Antigravity) has all three risk factors compounded: explicit named prohibition of third-party tools (OpenClaw, OpenCode, Claude Code by name in Google's FAQ), an active enforcement wave (Feb 2026), and no cost advantage to compensate. Reinstatement is friction-laden (Google Form, unclear SLA).
The temptation in a permissive design ("include everything that has a CLI") is to treat ToS as the provider's problem, not OLP's. That's wrong: OLP's user is the one whose account gets revoked. The temptation in a conservative design ("include only Tier D — fully safe") is to leave too much value on the table — the Chinese providers (MiniMax/GLM/Qwen) are token-economically attractive for the maintainer's usage patterns even with the consent-required overhead.
The structural middle path is a tiered framework with explicit consent UX for the optional tiers, modeled on the OCP ADR 0006 "Class A / Class B" precedent of using explicit categorization to manage scope risk.
One precision point requires emphasis. The Google Antigravity ban is **not** a "whole Google account ban." The verified scope (per the piunikaweb 2026-03-02 OpenClaw exec confirmation cited in spec §3.1) is *Google AI services tier*: Antigravity + Gemini CLI + Cloud Code Private APIs. Gmail, Drive, YouTube, Calendar, and other non-AI Google services are unaffected. Antigravity is excluded from OLP not on ban scope alone — a Google-AI-tier ban would still leave most of the user's Google services intact — but on the *combination* of: (a) explicit named prohibition of third-party tools, (b) no cost advantage versus Codex equivalents, and (c) reinstatement-process friction. Any one of these factors alone would not be sufficient for permanent exclusion; the combination is.
## Decision
OLP adopts a four-tier provider classification framework per spec §3.1:
**Tier A — Excluded.** Vendor's AI suite (note: scoped to AI services, not always whole account) suspended on enforcement; vendor documentation explicitly names third-party proxy tools as prohibited; subscription price does not differentially advantage the provider over Tier D alternatives. Bar is intentionally narrow: **all three conditions** must hold for Tier A exclusion. Single-signal triggers (e.g., a tightening clause with no enforcement) drop to Tier C, not A.
**Tier B — Optional, default-disabled, explicit consent required.** Service-level key revocation risk; vendor may extend revocation across that vendor's AI surfaces but does not touch non-AI services. ToS contains explicit anti-automation language. Enable requires a one-time consent UX: the consent prompt names the specific provider's policy clause and the realistic outcome (key revocation, not account suspension). Consent is recorded in `~/.olp/config.json` with a timestamp.
**Tier C — Optional, default-disabled, no consent UX.** ToS language has tightened (recent ToS update with anti-third-party direction) but no enforcement history exists. Enable is a config toggle; no consent prompt because the risk is signal-only rather than documented.
**Tier D — Default-enabled.** No anti-third-party clauses in subscription ToS; healthy ecosystem; permissive maintainer posture (where verifiable, e.g., maintainer-confirmed via official channel).
**Current classification table (v0.1):**
| Provider | Tier | Reason |
|----------|------|--------|
| Anthropic (Claude Code) | D | OCP-inherited. Pre-2026-06-15 subscription model was permissive. Post-2026-06-15 the credit-pool-vs-subscription split is a **quota constraint** within OLP's mission (manage it via fallback), not a **third-party prohibition**. ToS does not name third-party tools. |
| OpenAI Codex | D | Codex maintainer confirmed permissive in Codex GitHub Discussion #8338 (2026 reference). Codex CLI is the official Codex client; subscription auth via ChatGPT account is the intended UX. |
| Mistral Vibe (Le Chat Pro) | D | Usage Policy contains no anti-third-party / anti-automation clauses. Vibe is the official Mistral CLI; Le Chat Pro subscription explicitly includes Vibe. |
| xAI Grok Build | C | Jan 2026 ToS tightening introduced more restrictive automation language; no documented enforcement wave; uses API-key auth (not SuperGrok OAuth) to remain in the lower-risk lane. |
| Moonshot Kimi | C | "3 violations = ban" generic policy; no third-party-CLI-specific enforcement observed; API-key auth (not OAuth). |
| MiniMax | B | Token Plan policy prohibits automated scripts. Key revocation risk documented. Consent: key may be revoked; account remains usable. |
| Zhipu GLM | B | Coding Plan restricted to "officially supported tools." Key revocation risk. Consent UX: key may be revoked; account remains usable. |
| Alibaba Qwen | B | Coding Plan policy prohibits automation. Key revocation risk. Consent UX as above. |
| Google Antigravity | A | Feb 2026 enforcement wave restricted Antigravity + Gemini CLI + Cloud Code Private APIs for affected accounts. **Important scope correction:** the ban is *Google AI services tier* (not whole Google account); Gmail/Drive/YouTube are unaffected per piunikaweb 2026-03-02 OpenClaw exec confirmation. Excluded on the **combination** of (1) Google FAQ explicitly names OpenClaw/OpenCode/Claude Code as prohibited third-party tools — only provider in this survey with named-tool prohibition; (2) AI Pro $20 / Ultra $200 is not differentially cheap vs OpenAI Codex Plus $20 / Pro $200, so no cost advantage to taking on the risk; (3) reinstatement process exists but is via Google Form with unclear SLA. Any single factor would not justify Tier A; the combination does. |
**Consent UX for Tier B (per spec §3.1).** First-enable triggers a CLI prompt:
```
Enable MiniMax provider?
MiniMax's Token Plan policy prohibits automated scripts.
Realistic outcome of enforcement: your MiniMax API key may be revoked.
Scope: MiniMax service only. Other accounts unaffected.
This is a one-time consent. Type 'I understand' to enable, anything else to abort:
```
On 'I understand', `~/.olp/config.json` records `providers.minimax.consent_at: <ISO timestamp>` and the provider becomes enabled. Subsequent enables/disables are config toggles without re-prompting unless the consent record is cleared.
**Future provider additions.** Each new provider plugin (per ADR 0002) requires an accompanying inclusion ADR that:
1. Cites the provider's ToS / Usage Policy and any anti-third-party clauses found.
2. Cites enforcement history (or absence thereof).
3. Compares cost against existing Tier D providers — if no advantage, the addition must justify why the tier risk is worth taking.
4. Assigns a tier (A/B/C/D) using the framework above.
5. If Tier A: the plugin is not built. The ADR documents the exclusion for posterity.
6. If Tier B: the consent UX content is drafted in the ADR.
The inclusion ADR is a hard prerequisite for the plugin's merge — no ADR, no merge.
## Consequences
**Positive**
- Provider risk is enumerated and explicit. Users see in README's "Supported Providers" table which providers are Tier D / C / B / A and what the implications are. Surprise is structurally prevented.
- Tier B consent UX makes the user an active participant in the risk decision. They cannot accidentally enable a key-revocation-risk provider; they have to type 'I understand' literally.
- The framework is forward-compatible. New providers in 2027 / 2028 go through the same four-tier classification; the ADR per provider creates a documented trail of risk decisions over time.
- The Antigravity exclusion is documented at the framework level (not as an ad-hoc "we don't support this"), so future readers see *why* it's excluded — and specifically that the exclusion is on the combination of named-prohibition + no-cost-advantage + reinstatement-friction, not on ban-scope. The piunikaweb 2026-03-02 scope clarification is preserved in the ADR rather than degraded over time into folklore.
**Negative**
- Tier B's consent UX adds a step to first-enable. Users who already understand the risk perceive friction. The friction is intentional; the consent is the structural mechanism that protects the user.
- The framework is provider-by-provider, not category-by-category. A new Chinese provider similar to MiniMax/GLM/Qwen gets its own ADR even though the pattern is shared. This is intentional — boilerplate-and-paste ADRs are still cheaper than the alternative of "we just classed them together once and now we have a category drift problem when their ToS shifts."
- Tier-A exclusion is *permanent* in the current framework, with no documented reinstatement path. If Google were to walk back the named-tool prohibition and the cost picture changed, Antigravity would need a fresh ADR superseding this one. The framework deliberately makes Tier A "needs a new decision to revisit," not "auto-reconsidered each release."
**Mitigations**
- The README "Supported Providers" table is the user's first stop and surfaces tiers prominently. The dashboard (spec §4.6) shows tier per enabled provider so users see it on every dashboard load, not only at enable-time.
- Tier B consent prompts include the specific clause cited (e.g., "MiniMax Token Plan policy: 'No automated scripts'"). This avoids vague "this provider has terms" boilerplate that users would tune out.
- The framework's Tier A bar (three conditions, not one) is the structural counter-measure against "any provider with a tightening clause gets banned forever." Single-signal events drop to Tier C; only the combination justifies Tier A.
## Alternatives considered
**(a) Blanket "include everything that has a CLI."** Add Antigravity, add anything with a programmatic entry point, leave ToS judgment to the user. Rejected: this is the failure mode where the user's account gets revoked by Google because OLP's defaults said it was fine. OLP's defaults are part of OLP's contract with the user; they have to encode actual risk awareness.
**(b) Blanket "include only Tier D — fully safe."** Skip MiniMax, GLM, Qwen, Grok, Kimi entirely. Rejected: the Chinese providers are token-economically attractive enough that the consent UX is justified rather than blanket exclusion. The maintainer explicitly identified the Token Plan pricing as a reason to provide an opt-in path. A user choosing to opt in with consent is exercising informed choice; a blanket exclusion forecloses that choice.
**(c) Two-tier system (safe / risky) instead of four.** Simpler framework. Rejected: it conflates the Grok/Kimi case (signal-only, no enforcement history) with the MiniMax/GLM/Qwen case (explicit anti-automation language, documented revocation risk), which warrant different consent UX. Conflation either over-warns the Grok/Kimi user or under-warns the MiniMax user; four tiers separates the cases.
**(d) Per-provider ToS auto-check via a scheduled job.** A `bin/olp doctor` style command periodically fetches each provider's ToS and diffs against a stored snapshot, flagging changes. Rejected for v1.0: ToS pages are unstable HTML, anti-bot-protected, and frequently A/B-tested per region. Auto-checking is a project of its own; out of scope for v0.1. Manual review on each provider addition + annual re-review is the v1.0 substitute.
**(e) Document tier classifications in spec §3.1 only, no per-provider ADR for additions.** Lower-ceremony alternative. Rejected: a per-provider ADR creates the structural review gate (per ADR 0002) that prevents silent provider additions. The spec is updated alongside the ADR (decision log §10); the ADR carries the reasoning detail. Both layers earn their keep.
## Sources
- OLP v0.1 spec §3.1 (Scope — tiers, provider classifications, Antigravity exclusion reasoning)
- OLP v0.1 spec §10 (Decision log entries for the tier classifications)
- OCP ADR 0006 (OpenAI Shim Scope) — informs the risk-tier framing methodology of "explicit categorization with named criteria"
- Google FAQ on AI services (Feb 2026 enforcement wave) — referenced via spec §3.1
- piunikaweb 2026-03-02 OpenClaw exec confirmation on ban scope — referenced via spec §3.1
- Codex GitHub Discussion #8338 — Codex maintainer confirmation of permissive third-party-client posture
- 2026-05-23 provider deep-dive audit (on file per spec §11)
+37
View File
@@ -0,0 +1,37 @@
# Architecture Decision Records — OLP
This directory holds the OLP Architecture Decision Records (ADRs) — short documents that capture the **why** behind structural choices.
Read these before proposing governance, plug-in contract, IR, fallback, cache, or provider-inclusion changes.
## Numbering
ADRs in OLP start at `0001` (the project's founding decision). OLP did not inherit the `0001` placeholder convention OCP used; the project starts on a fresh numbering line.
New ADRs increment from the highest existing number. Filenames are `NNNN-<short-slug>.md`.
## Index
| ADR | Title | What it covers |
|---|---|---|
| [0001](0001-project-founding.md) | Project Founding | Why OLP exists (the 2026-06-15 Anthropic billing-split trigger), mission + non-mission boundaries, explicit supersession of OCP ADR 0005 "No Multi-Provider". |
| [0002](0002-plugin-architecture.md) | Plugin Architecture for Providers | The `lib/providers/<name>.mjs` plug-in model, the Provider contract (name / models / auth / spawn / estimateCost / quotaStatus / healthCheck / hints), static in-tree registry, why-not full external plugin discovery. |
| [0003](0003-intermediate-representation.md) | Intermediate Representation (IR) Design | The OLP-internal canonical request/response shape between the OpenAI-compat entry surface and each provider plugin. v1.0 IR fields, IR-vs-OpenAI-vs-native-provider three-shape model, IR is not exposed externally. |
| [0004](0004-fallback-engine.md) | Fallback Engine Semantics & Safety | Trigger taxonomy (Hard / Soft / Deterministic-deferred / Cost-aware-deferred), idempotent-failure safety (first-chunk rule), chain advancement one-at-a-time, observability headers. |
| [0005](0005-cache-cross-provider.md) | Cache Layer Cross-Provider Design | Cache key composition over `(provider, model, messages, …)`, per-model isolation, D1+D2+D3+D4 port from OCP v3.13.0, cross-provider fallback cache behaviour (correct miss). |
| [0006](0006-provider-inclusion.md) | Provider Inclusion / Exclusion + Risk-Tier Framework | The 4-tier classification (A excluded / B explicit consent / C opt-in / D default-enabled), current v0.1 inventory, Antigravity exclusion rationale (named prohibition + no cost advantage + reinstatement friction), consent UX, future provider addition procedure. |
## When to write a new ADR
Open one whenever:
- A structural rule is being added or changed (e.g., new IR field, new trigger type, new cache-key composition).
- A new provider plugin is being proposed (per ADR 0002 + ADR 0006 — ADR is a hard prerequisite for plugin merge).
- A decision encodes a lesson from an external event (provider policy change, drift incident).
- A future contributor reading the code alone could plausibly undo or re-litigate the choice.
Skip ADRs for routine implementation choices (algorithm pick, naming) — those belong in commit messages.
## Format
Keep ADRs short — Context / Decision / Consequences / Alternatives is the standard skeleton. Cite incidents, PRs, or commits where useful. Length per ADR ~150250 lines; longer than that, split.