* release(v0.5.1): hotfix — quota probe cache/backoff/schema-drift correctness (codex review) Addresses three production-quality findings from codex's post-v0.5.0 review (codex review on PR #57, reproduced with local mocks): F1 [P1] — Doctor bypass of cache + backoff (ADR 0013 Rule 3) `anthropic.quota_probe_reachable` called `_probeOnce(auth)` directly, bypassing `quotaProbeState.backoffUntil`. Successive `olp doctor` invocations within a backoff window each hit upstream — violating ADR 0013 Rule 3 (backoff is mandatory for ALL consumers). Fix: doctor now routes through `quotaStatus()`. ADR 0013 Rule 3 clarified: "All consumers of `quotaStatus()`, including `olp doctor` checks, MUST route through `quotaStatus()` and MUST NOT call `_probeOnce()` directly." F2 [P2] — 200 with empty ratelimit headers cached as live data (ADR 0013 Rule 5) A 200 OK with zero `anthropic-ratelimit-*` headers was cached as `stale: false` (live). Minimum-viable-schema gate added to `_probeOnce`: requires 5h-utilization + 5h-reset + 7d-utilization + 7d-reset present; absence → `failureKind: 'schema_drift'`, backoff scheduled, result NOT cached. ADR 0013 Rule 5 updated with the gate specification. F3 [P2] — Dashboard-data loses failure detail (ADR 0013 Rule 6) `aggregateProviderQuota()` collapsed all failure modes into `status: 'unavailable'` ("no public quota api or probe disabled") — same as providers with no API at all. Fix: `quotaStatus()` v0.5.1 contract — `null` ONLY for opt-in-off; failures return `{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }`. New `failure_kind` enum: no_credentials | auth_failed | rate_limited | schema_drift | network | other. Dashboard renders `unreachable` with red border + failure detail. Authority: ADR 0013 Rules 3, 5, 6 (cache + backoff + schema-drift + failure transparency) ADR 0008 Amendment 2 (richer quota_v2 shape; new unreachable status) ADR 0002 Amendment 8 unchanged (constitutional permission for the probe) Codex review findings F1–F3 (codex on PR #57) Changes: - lib/providers/anthropic.mjs: quotaProbeState gains lastError + failureKind; _probeOnce: min-field gate + failureKind population; quotaStatus(): v0.5.1 contract (null=disabled only; probe_status:live/stale/unreachable); doctorChecks routes through quotaStatus(); reset functions updated - lib/audit-query.mjs: _normalizeAnthropicQuota handles probe_status field; aggregateProviderQuota emits failure/failure_kind/backoff_until; unreachable status - dashboard.html: unreachable CSS classes + render path + footer v0.5.1 - test-features.mjs: 38f/j/l updated for v0.5.1 shape; 38r refactored for F1; 38g/k gain probe_status assertions; 38u/v/w new regression tests; 756→759 tests - docs/adr/0008: Amendment 2 (richer ProviderQuotaEntry + quotaStatus contract) - docs/adr/0013: Rule 3 clarification (doctor must use quotaStatus); Rule 5 min-viable-schema gate specification - package.json: 0.5.0 → 0.5.1 - CHANGELOG.md: v0.5.1 hotfix entry promoted from Unreleased - README.md / AGENTS.md: Phase 5 closed at v0.5.1; Phase 6 next Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * docs+code: PR #58 fold-in — reviewer Nits #1 + #2 (ADR doc-drift + opt_in_off enum) Fresh-context reviewer (PR #58) verdict: APPROVE_WITH_MINOR, 0 blocking, 4 nits. Folding in #1 + #2 (both 1-line cosmetic-but-correct fixes). Deferring #3 (test-only edge case in module-level seam restore) and #4 (pre-existing codex F4 — bin/olp.mjs + olp-plugin still read legacy quota shape; separate PR planned). Nit #1 — ADR 0002 Amendment 8 documentation drift. Amendment 8 at v0.5.0 line 20 said "the function returns `null` rather than throwing", and line 33 said "stale-cache-on-failure (`null` is returned only when no cache entry exists; if a stale entry exists it's returned with a `stale: true` marker)". v0.5.1 refined this contract: `null` is now reserved STRICTLY for opt-in-off, and all failure modes return `{ probe_status: 'unreachable' | 'stale', failure: {...} }`. The substantive idempotent-failure constraint (no throw to caller) is unchanged. The operational description in Amendment 8 was stale — fixed to cross-reference ADR 0008 Amendment 2 + ADR 0013 Rule 6 for the v0.5.1 contract refinement. Also references Suite 38 (38u/38v/38w) as the regression coverage producing the new shape. Nit #2 — `failureKind: 'opt_in_off'` declared but never produced. The enum value was listed in both the code comment (anthropic.mjs:250) and ADR 0008 Amendment 2 (line 30) but never actually assigned — because when opt-in is off, `quotaStatus()` returns the literal `null` BEFORE any state mutation happens. The enum value was dead. Fix: removed `opt_in_off` from both enum declarations + added an inline note explaining that consumers (audit-query, doctor) distinguish opt-in-off by checking `quotaStatus() === null`, not via failureKind. Deferred: - Nit #3 (test-seam restore edge case): if a caller pre-sets `_quotaAuthReadFnForTest` AND passes a non-default `_authReadFn`, the finally block restores to null clobbering pre-set value. Test-only impact, no production risk. Pure hygiene; defer. - Nit #4 (codex F4): bin/olp.mjs cmdUsage and olp-plugin/index.js still read legacy `body.quota` field, never consume `quota_v2`. Reviewer confirmed neither crashes — both gracefully fall through to "no quota api" branch. Out of scope for this hotfix per the hotfix dispatch contract; separate PR will migrate them. Tests: 759/759 still pass post-fold-in. No test changes needed. Authority: PR #58 review thread + ADR 0013 Rule 6 (failure transparency) + ADR 0008 Amendment 2 (ProviderQuotaEntry v0.5.1 shape). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: dtzp555 <dtzp555@gmail.com> Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
38 KiB
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)
Amendments
Note on numbering. Sequence is 1, 3, 4, 5, 6, 7 — Amendment 2 was never written. The reserved slot was originally planned for a separate
maxConcurrentratification, but that content was folded into Amendment 1 (the retroactive contract-sync amendment) at filing time and the gap was not backfilled. The gap is intentional and load-bearing — no missing content; do not renumber Amendments 3+ to close it (cross-references to Amendment N from other docs would silently break).
Amendment 8 — 2026-05-26: Permit quotaStatus() direct-API access (READ-ONLY exemption) for plan-usage probes (D79–D80 — Phase 5)
- Context: ADR 0012 (Phase 5 charter) opens 2026-05-26 to port OCP's plan-usage probe (
ocp/server.mjs:842-1109) intolib/providers/anthropic.mjs:quotaStatus(). The probe callsPOST https://api.anthropic.com/v1/messagesdirectly with an OAuth bearer and parsesanthropic-ratelimit-unified-*response headers. This violates the plugin contract's implicit assumption that ALL provider interaction goes throughspawn(the binary CLI).ALIGNMENT.mdRule 2 (provider-CLI-as-authority) further constrains plugins to operations the provider CLI itself performs. The OCP-derived plan-usage probe satisfies neither of these — it bypassesclaude -pand hits the public API directly. Without an explicit exemption Amendment, D80 is unalignable. - Why the exemption is sound: The probe is strictly READ-ONLY (one
POST /v1/messageswithmax_tokens: 1; the response body is discarded; only response headers are parsed) AND subscription-scope (the OAuth bearer is the same one Claude Code uses forclaude -p; no extra grant is requested) AND idempotent (probe failure returnsnull, never throws to a caller). The "what authority backs this?" answer is: Anthropic's CLI internally makes the same/v1/messagescall (verified 2026-05-26 bystringson the compiled binary — see~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md); the probe is mirroring an established CLI behaviour rather than introducing a new wire format. UnderALIGNMENT.mdRule 2, mirroring observed CLI behaviour is permitted; the Rule's intent is "don't invent wire formats Anthropic's CLI does not perform", which the probe respects. - Change — extend the Provider contract description:
quotaStatus(authContext): { quotaInfo }is now permitted to call provider HTTP APIs directly, subject to all three constraints:- READ-ONLY — the API call must not mutate provider-side state. POST is acceptable when the response is what's needed (Anthropic returns ratelimit headers on
POST /v1/messages); the request body MUST minimise side-effects (max_tokens: 1, dummymessages). - Subscription-scope reuse — the credentials used MUST be the same auth artifact the spawn path already reads via
readAuthArtifact(). No new OAuth grant, no new API-key registration, no separate scopes. - Idempotent failure — if the probe fails for any reason (network error, 401, 429, schema parse failure), the function returns a structured shape (
{ probe_status: 'unreachable', failure: { kind, message, backoff_until? } }since v0.5.1; see ADR 0013 Rule 6 + ADR 0008 Amendment 2) rather than throwing. The caller (server.mjs / dashboard /olp usageCLI) gracefully degrades. At v0.5.0 the failure shape was the literal valuenull; v0.5.1 refined this to a structured shape so operators can distinguish auth failures from rate-limit failures from network failures from in-backoff stale-cache. The substantive idempotent-failure constraint (no throw to caller) is unchanged.
- READ-ONLY — the API call must not mutate provider-side state. POST is acceptable when the response is what's needed (Anthropic returns ratelimit headers on
healthCheck()and other contract methods are NOT extended by this Amendment. OnlyquotaStatus()may make direct API calls. A plugin that wants live data for any other contract method must continue to usespawnorreadAuthArtifact.- The probe MUST cache its result. Recommended TTL: 5 minutes (mirrors OCP
USAGE_CACHE_TTL). Tighter TTLs (e.g. dashboard's 1-minute refresh) are served from the cached value if fresh; cache miss triggers a real probe. - The probe MUST implement exponential backoff on refresh failures: minimum 60s, maximum 3600s (mirrors OCP
OAUTH_REFRESH_MIN_BACKOFF/OAUTH_REFRESH_MAX_BACKOFF). Tight loop on failure has historically burned through Anthropic's rate limit in seconds (OCP institutional lesson 2026-04). - The probe MUST be opt-in via
~/.olp/config.json(providers.<name>.quota_probe_enabled: true; defaultfalse). Reasoning: a fresh OLP install on a machine without OAuth credentials should not bombardapi.anthropic.comwith 401-bound probes; the operator opts in once the credentials are configured.
- What this Amendment does NOT permit:
- Mutating API calls (e.g. POST/PATCH/DELETE that change provider-side state). Still forbidden.
- API calls for any contract method other than
quotaStatus().spawn/healthCheck/doctorChecks/estimateCost/models/hints/name/displayName/authremain spawn-and-filesystem-only. - Per-provider new auth grants. The probe uses the spawn path's existing credentials.
- Bypassing the alignment.yml blacklist. The hallucinated
/api/oauth/usagetoken stays blacklisted; the probe uses/v1/messages(real endpoint). - API calls to endpoints not explicitly enumerated by the companion ADR 0013 § Rule 2. Amendment 8 permits the kind of call (READ-ONLY direct API for quota probing); ADR 0013 Rule 2 enumerates which specific endpoint is permitted. A future reader of Amendment 8 alone should NOT infer that any READ-ONLY/idempotent endpoint is fair game — the per-endpoint containment is locked to ADR 0013. Re-opening per-endpoint scope requires an ADR 0013 amendment, not a new plugin-level interpretation of Amendment 8.
- Backwards compatibility: Plugins whose
quotaStatus()still returnsnull(mistral at v0.5.0 pending D84 audit, codex permanently per Phase 5 charter) are NOT affected. No existing behaviour changes for them. - Authority cited at the implementation: D80 commit cites this Amendment +
~/.cc-rules/memory/learnings/anthropic_plan_usage_probe_schema_2026_05_26.md+Claude Code v2.1.x § OAuth bearer + ratelimit-unified headers+ live-probe transcript from 2026-05-26 in the commit body. ALIGNMENT.md Rule 1 + Rule 5 (CI) both satisfied. - Tests: Suite 38 (Phase 5 D83) covers the probe: mock HTTP server returning all 13
anthropic-ratelimit-unified-*headers; assert parse correctness for each; assert 5min cache; assert 60s-3600s exponential backoff on simulated 429; assert stale-cache-on-failure. At v0.5.0 stale-failure returned{ stale: true, ... }withnullreserved for no-cache failures; v0.5.1 refined the return contract —nullis now reserved STRICTLY for opt-in-off, and all failure modes (auth / rate-limit / schema-drift / network / no-creds) return{ probe_status: 'unreachable' | 'stale', failure: {...} }. Seetest-features.mjsSuite 38 (38u/38v/38w added for the v0.5.1 hotfix regression coverage of F1 / F2 / F3 per codex review). - Procedural mechanism: Iron Rule 11 (IDR) — this Amendment, ADR 0012 (Phase 5 charter), and ADR 0013 (OAuth READ-ONLY consumption rules) land together at D79 as a single coupled commit. Reviewing them separately cannot verify consumer-producer alignment. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
Amendment 7 — 2026-05-26: Add OPTIONAL doctorChecks() to the Provider contract (D67 — Phase 4 operator UX)
- Context: ADR 0010 § Phase 4 D64-D67 ships
bin/olp.mjsoperator CLI +olp doctorframework.olp doctorruns a set ofCheckobjects (id / category / asyncrun()returning{ status, message, evidence? }) and discriminates the next remediation step via akindfield (noop/fix_server/fix_oauth/fix_provider/fresh_install). The framework needs per-provider checks so a user with a brokenclaudeinstall gets a different fix recipe than a user with a brokenvibeinstall. Hardcoding the recipes inbin/olp.mjswould re-introduce the kind of per-provider knowledge drift that ADR 0002 § Decision exists to prevent — when a new provider plugin lands, the operator CLI would have to be edited too. - Change — add to Provider contract:
- Introduce OPTIONAL
doctorChecks()returningDoctorCheck[]where eachDoctorCheckhas the shape:id: string— unique per check, conventionally<provider>.<probe-name>(e.g.anthropic.cli_available,anthropic.oauth_token_present).category: 'provider'— fixed for plugin-contributed checks. The framework reserves'server','auth','config','system'for built-in checks.async run(): { status: 'ok' | 'fail' | 'warn', message: string, evidence?: { fix_commands?: string[], human_steps?: string[], reference?: string } }— runs the probe.status: 'fail'makesolp doctorexit non-zero and contributes to thekind: fix_providerdiscriminator;evidence.fix_commands[]is concatenated intonext_action.ai_executable[]andevidence.human_steps[]intonext_action.human_required[].
- Introduce OPTIONAL
- Backwards compatibility: Plugins that omit
doctorChecks()contribute zero provider checks. Their healthCheck() return value continues to flow through/health.providers.status.<name>exactly as today. No existing plugin behaviour changes; no existing test breaks.validateProviderinlib/providers/base.mjsis updated to type-checkdoctorChecksonly when present (must be a function); absence is allowed. - What
doctorChecks()is for vs. whathealthCheck()is for:healthCheck()answers "is this provider currently usable?" — checked at the request-execution layer; output feeds/healthand per-request retry decisions.doctorChecks()answers "if this provider is broken, what specific actionable steps fix it?" — checked at the operator layer; output feedsolp doctor+ thenext_action.ai_executable[]repair templates that a downstream AI agent can paste-and-run.
- Suggested probe set (per plugin):
<provider>.cli_available— spawn<bin> --versionwith short timeout (≤3s); fail → fix_commands include install instruction.<provider>.<auth-artifact>_present— check whether the auth file / env var the plugin'sreadAuthArtifact()reads is populated; fail → human_steps include the login command (which usually requires browser interaction and so cannot be inai_executable[]).
- Authority: ADR 0010 § Phase 4 D64-D67 (this is the addition called out by that charter). No provider CLI doc citation needed —
doctorChecks()is an internal contract field. Implementation lands in D67 (this PR):lib/providers/anthropic.mjs,lib/providers/codex.mjs,lib/providers/mistral.mjseach gain adoctorChecks()method coveringcli_available+<auth-artifact>_present. - Tests: Suite 32 (
bin/olp.mjsCLI smoke) and Suite 33 (olp doctorframework) intest-features.mjscover the contract amendment. Suite 33 specifically asserts: (a) a plugin withoutdoctorChecks()contributes no provider checks (default behaviour), (b) a plugin with a failingdoctorChecks()probe triggerskind: fix_providerand propagates itsevidence.fix_commands[]intonext_action.ai_executable[], (c) all-passing checks yieldkind: noop. - Procedural mechanism: CC 开发铁律 v1.6 § 11 (IDR) — the contract amendment, the plugin implementations, the doctor framework, and the CLI scaffold are tightly coupled. They land as a single PR (D64-D67 bundle) because reviewing them separately cannot verify that consumer + producer line up. Iron Rule 10 fresh-context reviewer per CLAUDE.md hard requirement #3.
Amendment 6 — 2026-05-24: maxConcurrent runtime enforcement landed (D38, issue #1)
- Finding: Amendment 1 (2026-05-23) ratified
maxSpawnTimeMsinto the Provider contract but explicitly noted thathints.maxConcurrentremained declarative-only at v0.1 — type-validated at startup inlib/providers/base.mjs(validateProviderrequires it to be a non-negative integer) but unenforced at runtime (no semaphore / in-flight counter / spawn queue inserver.mjs). Cold-audit catch from D11 (commitf659e29): the diff-review reviewer grep-verified that the original ADR draft's claim "Enforced by the spawn-concurrency guard inserver.mjs" was false. GitHub issue #1 was filed to track the gap. D38 closes that gap. - Change (D38):
- Add a per-provider in-flight semaphore in
lib/providers/index.mjsexporting three primitives plus a constant:tryAcquireSpawn(providerName, maxConcurrent)— atomic check-then-increment; returnstrueon success,falseif at limit. Atomicity rests on the JS single-threaded invariant — the read and write are synchronous with NOawaitbetween them. A future async refactor MUST preserve this.releaseSpawn(providerName)— decrement; throws if the count would go negative (defensive bug guard for missing acquire / double release).getActiveSpawnCount(providerName)— returns current in-flight count; exported for diagnostics and tests (server.mjs uses it to populate theactiveSpawnsfield on a synthesisedCONCURRENCY_LIMITerror)./healthintegration deferred — when surfaced there it will land atproviders.status.<name>.activeSpawns; not wired at D38.DEFAULT_MAX_CONCURRENT_SPAWNS = 4— defense-in-depth fallback when a plugin path bypassesvalidateProviderand passes undefined/null/NaN. The value matches the v0.1 plugin defaults (anthropic / codex / mistral all declarehints.maxConcurrent: 4).
- Wire the gate at both
provider.spawn(...)call sites inserver.mjs handleChatCompletions:- Buffered path (inside
executeHopFn → collectAllChunks):tryAcquireSpawnruns beforeprovider.spawn(...). On failure, synthesiseProviderError(CONCURRENCY_LIMIT)withproviderName/maxConcurrent/activeSpawnsfields for diagnostics and re-throw — the fallback engine treats it as a hard trigger (see ADR 0004 Amendment 4) and advances to the next chain hop. On success, the spawn drain loop runs inside atry { … } finally { releaseSpawn(...) }so the slot releases on every exit path (success, error, D16 SPAWN_FAILED salvage return, unexpected throw). - Streaming path (single-hop real-SSE,
chain.length === 1cache-miss branch): acquire happens BEFORE the streaming branch entry. If acquire fails, the branch is skipped and the request falls through to the buffered path — that path's own gate re-attempts acquire; a single-hop chain at maxConcurrent has no other hop to advance to, so the request surfaces a chain-exhausted error viaexecuteWithFallback's exhaustion path. If acquire succeeds, the existing streaming try/catch gains afinally { releaseSpawn(streamProvider) }so the slot releases on stop-chunk completion, generator exhaustion, abort, or any exception path.
- Buffered path (inside
- Add
CONCURRENCY_LIMITtoPROVIDER_ERROR_CODESinlib/providers/base.mjsso the synthesised error type-checks with the existing closed enum. (Note:CONCURRENCY_LIMITis synthesised by the orchestration layer, NOT thrown by provider plugins themselves — the code is in the enum for type consistency with the fallback engine'sHARD_TRIGGER_CODESlookup.) - Update the
maxConcurrentdescription in § Decision (Provider contract hints) below — remove the "Declarative hint only at v0.1" caveat and add the implementation reference.
- Add a per-provider in-flight semaphore in
- Update to § Decision § Provider contract hints (
maxConcurrent): replace the v0.1 caveat with: "maxConcurrent— integer; maximum simultaneous spawn count OLP will allow for this provider. The value is type-validated at startup (lib/providers/base.mjs validateProvider) and enforced at runtime bytryAcquireSpawn/releaseSpawninlib/providers/index.mjs. Saturation surfaces asProviderError(CONCURRENCY_LIMIT)(perPROVIDER_ERROR_CODES,lib/providers/base.mjs), which the fallback engine treats as a hard trigger per ADR 0004 Amendment 4 — the chain advances to the next hop. If the entire chain is saturated, the user receives a chain-exhausted error via the existingexecuteWithFallbackexhaustion path." - Design choice — immediate-advancement vs. queue+timeout: D38 implements immediate-advancement through the fallback chain. Rationale:
- The fallback chain exists precisely for this kind of overflow — saturation on the primary hop is a natural fit for the existing advancement mechanism.
- Queue+timeout introduces head-of-line blocking risk (a stuck/slow spawn blocks queued waiters) and adds a new timeout config surface (
hints.maxConcurrentWaitMs?) that the contract does not currently have. - Immediate-advancement gives fail-fast latency and matches the OLP multi-provider proxy philosophy (the user has spread their quota across providers explicitly so saturation should reach an alternate provider as fast as possible).
- Queue+timeout is deferred to a future iteration if real usage shows demand. Track via a follow-up issue if the design pressure surfaces.
- Authority: ALIGNMENT.md Rule 1 (Cite First) — internal authority is ADR 0002 (this ADR) + ADR 0004 (which adds CONCURRENCY_LIMIT to the hard-trigger taxonomy in its Amendment 4). No provider CLI doc cited because this change is internal to the orchestration layer; no provider plugin code changes (anthropic / codex / mistral already declare
hints.maxConcurrentcorrectly per validateProvider). - Tests: Suite 18 in
test-features.mjs— 16 tests covering:PROVIDER_ERROR_CODESmembership,evaluateHardTriggers(CONCURRENCY_LIMIT)returns true, semaphore unit behaviour (acquire / release / count / reset), saturation rejection, defensive coercion of non-integer maxConcurrent, double-release throws, HTTP-level concurrent-request peak-in-flight assertion (5 requests against maxConcurrent:2 → peak == 2), buffered-path counter release, streaming-path counter release, fallback advancement to secondary on saturated primary. - Procedural mechanism: CC 开发铁律 v1.6 § 10.x (D38 issue #1 implementation; fresh-context opus reviewer to follow this implementation per Iron Rule 10).
Amendment 5 — 2026-05-24: Correct § Decision filesystem layout — vibe.mjs → mistral.mjs (D36 #5)
- Finding: Issue #5 (D36) — § Decision filesystem layout (around line 47 of the original ADR) listed the Mistral provider plugin as
vibe.mjs(named after the CLI binaryvibe). The shipped file atlib/providers/mistral.mjs(D8) is named after the provider key, matching the established convention from the other two plugins:anthropic.mjs(provider keyanthropic, CLIclaude) andcodex.mjs(provider keyopenai, CLIcodex). The ADR'svibe.mjsentry was a drafting-time placeholder that did not get corrected when D8 landedlib/providers/mistral.mjs. - Change: Replace
vibe.mjs # spawnvibe --prompt --output jsonwith `mistral.mjs # spawn `vibe --prompt --output streamingin the filesystem layout. The--output streamingcorrection also aligns the example with the actual D8 implementation (mistral.mjsline 377 uses--output streaming, not--output json— see D8 review-2 finding inside the plugin header). - Naming convention reaffirmed: Provider plugin files are named after the provider key (
anthropic,openai,mistral), not the CLI binary (claude,codex,vibe). Future provider plugins must follow this convention. The provider key is the load-bearing identifier — it appears inmodels-registry.json, cache keys, fallback chain configs, and ADR 0006 inclusion tables. The CLI binary name is an implementation detail that may change (e.g., a vendor rename) without affecting the rest of the system. - Authority: Issue #5 (D36); naming convention established by
lib/providers/anthropic.mjs(D4) andlib/providers/codex.mjs(D6) which both shipped beforelib/providers/mistral.mjs(D8). - No code change: D36 #5 is a docs-only correction. The plugin file already lives at the correct path.
- Procedural mechanism: CC 开发铁律 v1.6 § 10.x (D36 batch — ADR drift caught by issue-triage review of bootstrap ADRs).
Amendment 4 — 2026-05-24: Ratify contractVersion as a required Provider contract field (D32 F5)
- Finding: Round-4 cold-audit F5 (P3 governance omission) —
lib/providers/base.mjsvalidateProviderenforcesp.contractVersion === '1.0'and all three shipped plugins declare it, but the Provider contract field list in § Decision (lines ~63-74) does not includecontractVersion. It was mentioned only in § Mitigations as a forward-looking note ("The contract is versioned. v1.0 is the subset in this ADR; future additions … require ADR amendment plus a contract-version bump. Old provider plugins continue to declarecontractVersion: '1.0'…"), not as a required field. This is the same class of documentation–implementation gap as Amendment 1 (maxSpawnTimeMsretroactive sync). - Change: Add
contractVersion: '1.0'as the 10th required field in the Provider contract field list in § Decision. Thehintsblock moves from 9th to 10th entry to keep the list logically ordered (name → displayName → models → auth → spawn → estimateCost → quotaStatus → healthCheck → contractVersion → hints).validateProviderinbase.mjsalready enforces it; this amendment is a retroactive documentation sync, not a code change. - Semantics:
contractVersionis a required string field set to'1.0'for all v0.1 plugins. Its purpose is to allow the loader to detect plugins authored against an older or newer contract version when OLP's contract evolves. A future contract revision (v1.1 or v2.0) will increment this value and update this ADR accordingly. - Authority:
lib/providers/base.mjsvalidateProviderfunction — live enforcement as of v0.1 bootstrap. All three shipped plugins (anthropic.mjs,codex.mjs,mistral.mjs) already exportcontractVersion: '1.0'. - Procedural mechanism: CC 开发铁律 v1.6 § 10.x (Round-4 Cold Audit caught it as F5). Parallel to Amendment 1 (D11
maxSpawnTimeMsretroactive sync).
Amendment 3 — 2026-05-24: Add cacheable to Provider contract hints (D23)
- Finding: Cold-audit round-2 Finding 3 (P2 contract/cache-condition drift) — ADR 0005 § "Cache write conditions" item 3 references
hints.cacheablebut the field was never named in this ADR's Provider contract hints list.validateProviderdid not enforce it; no plugin declared it; no consumer read it. The field existed only in prose. - Change: Add
cacheable: boolean (optional, default true)to the contract hints. Plugins that omit it are treated ascacheable: true(backward-compatible default — preserves v0.1 behavior for the three shipped plugins). A plugin author who explicitly setscacheable: falseopts out of OLP's response cache entirely:executeHopFninserver.mjsskipscacheStore.getOrComputeand callscollectAllChunksdirectly; neithercache.getnorcache.setis called for that hop. - Rationale: Some providers may be cheap and stateful enough that caching adds risk without value (e.g., providers with strong intra-session continuity, or providers whose output is intentionally non-deterministic). Giving plugins an explicit opt-out keeps the design honest without imposing a runtime cost on the common case (all three shipped plugins are
cacheable: true). - Authority: ADR 0005 § "Cache write conditions" item 3 — established the field; D23 ratifies it in the contract.
- Procedural mechanism: CC 开发铁律 v1.6 § 10.x (Round-2 Cold Audit caught it as Finding 3).
Amendment 1 — 2026-05-23: Add maxSpawnTimeMs to Provider contract hints (retroactive sync)
- Finding: Cold-audit Finding 4 (P2 governance violation) — commit
2cfd0b1(D10) addedmaxSpawnTimeMsto all three provider plugins (anthropic.mjs,codex.mjs,mistral.mjs) — enforcement lives inside each provider plugin's spawn drain loop, which throwsProviderError(SPAWN_TIMEOUT)that the fallback engine then treats as a hard trigger (ADR 0004 § Trigger taxonomy bullet 4) — but the Provider contract documentation in this ADR was not updated in the same merge. - Code already landed: The corresponding implementation is live in commit
2cfd0b1(D10). This amendment is a retroactive contract sync to restore documentation–implementation alignment per ALIGNMENT.md Rule 1 (Cite First) andCLAUDE.md§ "Hard requirements for plugin / server.mjs / IR changes" item 1 (Authority citation) — both of which require contract additions to be authority-cited at landing. ALIGNMENT.md Rule 2(c)'s literal wording covers IR fields; its spirit extends to Provider-contract additions. - Procedural mechanism: CC 开发铁律 v1.6 § 10.x (Diff Review vs Cold Audit mode). The diff-review pass that approved D10 missed this omission; the subsequent cold-audit run on 2026-05-23 caught it as Finding 4. This amendment is the required remediation.
- Authority: ADR 0004 § Trigger taxonomy — Hard triggers bullet 4: "Provider CLI spawn timeout (configurable per-provider via
hints.maxSpawnTimeMs)."
Context
OLP declares a curated set of candidate providers — three anticipated Tier D (Anthropic, OpenAI Codex, Mistral Vibe), two anticipated Tier C (xAI Grok, Moonshot Kimi), and three anticipated Tier B (MiniMax, Zhipu GLM, Alibaba Qwen). Per ALIGNMENT.md § Provider Inventory, all 8 ship as Candidate at v0.1 founding; transition to Enabled requires authority pin filled + plugin landed + Phase audit passed. 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:
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`
mistral.mjs # spawn `vibe --prompt --output streaming` (file named after
# provider key per the convention established by
# anthropic.mjs / codex.mjs — see Amendment 5)
grok.mjs # spawn `grok -p --output-format streaming-json` (optional)
kimi.mjs # spawn `kimi -p --output-format stream-json` (optional)
minimax.mjs # tier-2 optional, default-disabled
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 UXmodels: string[]— models this provider servesauth: { type, storage, path, refresh }— auth-artifact profilespawn: async (normalizedRequest, authContext) => AsyncIterator<ResponseChunk>— the core invocationestimateCost: (request) => { inputTokens, outputTokensEstimate, currency, usd }— best-effort, may return nullquotaStatus: async (authContext) => { available, percentUsed, resetsAt, pool }— best-effort, null if unretrievablehealthCheck: async () => { ok, latencyMs, error? }— startup and/healthendpoint use thiscontractVersion: '1.0'— required string identifying the Provider contract version the plugin was authored against. Set to'1.0'for all v0.1 plugins.validateProviderinbase.mjsenforces this field. Future contract revisions will increment the value; the loader uses it to detect stale plugins. See Amendment 4. (D32 F5)hints: { requiresTTY, concurrentSpawnSafe, maxConcurrent, maxSpawnTimeMs, cacheable }— fingerprint, concurrency, timeout, and cache hints:requiresTTY— boolean; whether the provider CLI requires a TTY to produce non-interactive output (e.g., some CLIs suppress JSON output unless forced with a flag or a TTY is present).concurrentSpawnSafe— boolean; whether the provider CLI is safe to spawn concurrently under the same auth context without rate-limit or session collisions.maxConcurrent— integer; maximum simultaneous spawn count OLP will allow for this provider. The value is type-validated at startup (lib/providers/base.mjs validateProvider) and enforced at runtime bytryAcquireSpawn/releaseSpawninlib/providers/index.mjs(D38 — see Amendment 6). Saturation surfaces asProviderError(CONCURRENCY_LIMIT), which the fallback engine treats as a hard trigger per ADR 0004 Amendment 4 — the chain advances to the next hop. If the entire chain is saturated, the user receives a chain-exhausted error via the existingexecuteWithFallbackexhaustion path. (Pre-D38 caveat removed; tracking issue #1 closed by Amendment 6.)maxSpawnTimeMs— optional integer, milliseconds; maximum wall-clock time OLP allows for a single provider spawn before treating it as a hard fallback trigger. Defaults to600000(10 minutes) if absent. Enforcement lives inside each provider plugin's spawn drain loop (_spawnAndStream), which uses asetTimeout/proc.kill/rejectpattern to throwProviderError(SPAWN_TIMEOUT); the fallback engine then treats this error as a hard trigger (ADR 0004 § Trigger taxonomy — Hard triggers bullet 4). The engine itself does not run the timer loop; it only acts on the thrown error.cacheable— optional boolean, defaulttrue; if explicitly set tofalse, the provider opts out of OLP's response cache entirely.executeHopFnskipscacheStore.getOrComputeand callscollectAllChunksdirectly; no cache read or write occurs for any request to this provider. Omitting the field is equivalent tocacheable: true. See ADR 0005 § "Cache write conditions" item 3 and Amendment 3 above. (D23)
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:
- Write
lib/providers/<name>.mjsconforming to the contract. - Add one import + one entry to
lib/providers/index.mjs. - Add a row to README's "Supported Providers" table.
- 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.mjsedits required. The recipe is explicit (file + registry + README + ADR), so a future contributor (including a future Claude session) cannot accidentally do steps 1–2 without 3–4. - The contract is the test surface. A provider plugin can be tested in isolation against a contract conformance suite (
test-features.mjsextended per spec §6 Phase 1), independent ofserver.mjs. server.mjsstays 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
streamingModeortoolUseShape) 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.mjsexists to absorb the truly-shared parts, but resisting the temptation to push provider-specific logic intobase.mjsrequires 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.mjsprovides shared helpers but does not implement theProvidercontract 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 acostPerTokensnapshot) require ADR amendment plus a contract-version bump. Old provider plugins continue to declarecontractVersion: '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 8 candidate providers (anticipated 3 D + 2 C + 3 B, eight code paths once they all transition from Candidate to Enabled). 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 ~150–400 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.jsonas SPOT) — informs the "static enumeration, not filesystem scan" loading model - OCP ADR 0005 — the context paragraph references OCP's
server.mjsreaching 1667 lines at one provider; the plugin architecture is the structural response to that complexity scaling N×