mirror of
https://github.com/dtzp555-max/olp.git
synced 2026-07-22 13:35:10 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6605b7b14a | ||
|
|
6edf6e0b94 | ||
|
|
f3716a19fd |
@@ -6,6 +6,72 @@ All notable changes to OLP land here. Per `CLAUDE.md` release_kit overlay, this
|
|||||||
|
|
||||||
(empty — Phase 5 entries land here once Phase 5 opens)
|
(empty — Phase 5 entries land here once Phase 5 opens)
|
||||||
|
|
||||||
|
## v0.4.3 — 2026-05-26
|
||||||
|
|
||||||
|
### D76 — README install-path overhaul + `OLP_BIND` env + AI-driven install prompt + ADR 0011 amendment
|
||||||
|
|
||||||
|
Patch release closing the install-experience gap. v0.4.0–v0.4.2 README's Quick Start was placeholder text with fictional commands (`npm install -g @dtzp555-max/olp` — package isn't published; `olp setup` / `olp start` — don't exist). 10 real gaps catalogued + fixed in one D-day; `OLP_BIND` env wired so the documented LAN onboarding flow actually works; AI-driven install prompt added per the Phase 4 charter brainstorm's #2 OCP inheritance candidate (was deferred at D64-D67 to the doctor framework only; D76 closes the README half).
|
||||||
|
|
||||||
|
- **G1-G7 (README "Quick Start" was fictional)** — rewrote § "Manual install" with the real sequence: prerequisites (Node ≥ 18 + provider CLI install matrix) → `git clone` → `npm test` verify → `olp-keys keygen --owner` first → provider OAuth (claude/codex/mistral per-CLI flows) → write `~/.olp/config.json` with the minimum that actually serves traffic → `npm start` → smoke-test → IDE pointing. Each step empirically verified against the PI231 + Mac mini E2E session (2026-05-26).
|
||||||
|
- **G8 (LAN unreachable — F5)** — added `OLP_BIND` env (default `127.0.0.1`). Operators set `OLP_BIND=0.0.0.0` (or a specific LAN IP) to accept LAN connections so `olp-connect <ip>` can actually reach the server. Pre-D76 the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`, making the documented LAN-onboarding flow only usable through an SSH tunnel. ADR 0011's original wording referenced a `BIND_ADDRESS` concept that didn't exist; D76 makes it operational.
|
||||||
|
- **G10 (no AI-install pattern)** — README § "Install with your AI (the fast path)" added. Verbatim prompt that the operator pastes into Claude Code / Cursor / Copilot / Aider; the AI follows the README + uses `olp doctor --json` machine-readable `next_action.ai_executable[]` (D64-D67) for self-repair, stopping only when `human_required[]` is non-empty (the provider OAuth dances). This closes the Phase 4 brainstorm Top-5 inheritance candidate #2 — the OCP "paste this prompt" pattern that D64-D67 only half-built.
|
||||||
|
- **Opening compressed** — § "Why OLP" (3 paragraphs of OCP billing history) removed from the top. The OCP-trigger context moved to § "Migration from OCP" at the bottom, condensed into a single paragraph. New users land on value-prop + § "What you get" + § "Install with your AI" / § "Manual install" without needing to digest 2026-05-14 / 2026-06-15 Anthropic billing history first. OCP users get a one-line pointer at the top.
|
||||||
|
- **§ "Configuration" full schema documentation** — replaced the placeholder with the actual `~/.olp/config.json` schema including every field that v0.4.x reads. Cross-references ADR 0004/0007/0010/0011.
|
||||||
|
- **§ "Environment Variables" extended** — added `OLP_BIND`, `OLP_API_KEY`, `OLP_OWNER_TOKEN`, `OLP_PROXY_URL` rows that were used throughout the manual-install flow but undocumented.
|
||||||
|
|
||||||
|
**ADR 0011 § "Deployment configurations" amendment.** Codifies the three deployment trust contexts (`127.0.0.1` loopback / RFC1918 + tailnet LAN / `0.0.0.0` public — with `advertise_anonymous_key: true` only safe in the first two). Documents the new `anonymous_key_advertised_with_lan_bind` startup warn event. Closes ADR 0011's pre-D76 dangling reference to a non-existent `BIND_ADDRESS`.
|
||||||
|
|
||||||
|
**Test count:** 714 (v0.4.2) → 717 (v0.4.3). +3 D76 regression tests in Suite 36 (36s/36t/36u) pinning `OLP_BIND` wiring + safety warn + ADR amendment.
|
||||||
|
|
||||||
|
**Out of D76 scope (deferred):**
|
||||||
|
- F6 (doctor client-side vs server-side check separation) — needs design ADR for a `--remote` mode. Phase 5.
|
||||||
|
- D75 reviewer P2-1 (ADR 0004 amendment for per-hop schema) + P2-2 (defensive `typeof hopModel === 'string'`) — both genuine follow-ups, neither blocking.
|
||||||
|
- `scripts/migrate-from-ocp.mjs` — Phase 7.
|
||||||
|
|
||||||
|
**Authority:** PI231 + Mac mini E2E session (2026-05-26, post-v0.4.2 verification revealed the 10 README gaps); ADR 0011 amendment self-cites; Phase 4 charter (ADR 0010) Top-5 inheritance candidate #2 (AI-driven self-repair). Process learning: every D-day reviewer rubric should add "open README in §-Quick-Start and verify the commands literally exist + work in the current repo" — would have caught G1-G7 at v0.4.0.
|
||||||
|
|
||||||
|
## v0.4.2 — 2026-05-26
|
||||||
|
|
||||||
|
### Post-v0.4.1 hotfix batch (D75) — real-machine E2E findings
|
||||||
|
|
||||||
|
Patch release fixing 5 bugs caught by **real-machine E2E testing on PI231 + Mac mini (2026-05-26 session)** — bugs that prior D-day reviewers AND the post-v0.4.0 maintainer review both missed because they reviewed against spec text and against the local OLP install's `~/.codex/auth.json` shape (cached from an older codex CLI version), not against real provider CLIs running on a remote operator host that did `npm install -g @openai/codex` for the first time on 2026-05-26 and got codex CLI v0.133.0.
|
||||||
|
|
||||||
|
**Root cause of the missed-bug class.** D6 (codex plugin authoring) explicitly documented three unpinned assumptions (A3 = access-token field name, A4 = NDJSON event schema, A2-adjacent = trusted-directory sandbox). D6 noted "D7 E2E will pin." D7 then shipped without performing real-codex-CLI E2E (the E2E gating mark was carried but the actual run was deferred). Every subsequent D-day reviewer trusted the D6/D7 codex plugin code unchanged because the static review couldn't see that the v0.133.0 CLI had moved the auth-token field, the event schema, AND added a new trusted-directory sandbox flag. The D74 maintainer review focused on `/health` / `/cache/stats` / `/v0/management/dashboard-data` payload shapes — none of which exercise the codex plugin's spawn path. F7 (per-hop model override) is a different class of miss — every reviewer read `executeHopFn(provider, model, ir)` and saw `model` consumed for cache key + audit ctx, but none traced through to confirm `model` is ALSO substituted into the IR passed to `provider.spawn()`. The function signature implied per-hop semantics that the body never fully delivered.
|
||||||
|
|
||||||
|
- **[F1] codex auth.json schema pin — codex CLI v0.133.0 nests the access token under `tokens.access_token`** (verified empirically on PI231 / Mac mini, 2026-05-26). Pre-D75 `readAuthArtifact()` read only top-level `creds.access_token` / `creds.token` / `creds.accessToken` — all undefined under v0.133.0 → returned `null` → OLP reported "auth artifact missing" via `/health` and `olp doctor` AND refused to spawn codex even when the user had fully completed `codex login`. Fix: prepend `creds?.tokens?.access_token` to the precedence chain at BOTH call sites (`OPENAI_CODEX_AUTH_PATH` override branch + default `$CODEX_HOME/auth.json` branch). Legacy top-level fields preserved as fallback for backward compat with older codex CLI versions.
|
||||||
|
- **[F2] codex spawn args — codex CLI v0.133.0 trusted-directory sandbox requires `--skip-git-repo-check`.** v0.133.0 refuses with `"Not inside a trusted directory and --skip-git-repo-check was not specified."` when spawned outside a git repo, exits non-zero with zero NDJSON output → OLP surfaces `SPAWN_FAILED` with no usable chunks → the fallback engine advances to next hop unnecessarily even when codex is configured and authenticated. OLP's typical deploy CWD (`~/olp/`) is NOT a git repo on operator hosts. Fix: add `'--skip-git-repo-check'` to the args array before `--model`. OLP is the trusted caller (operator's own server invoking the operator's own subscription via documented `codex exec` automation); the sandbox safeguards interactive shells, not pre-authorized automation.
|
||||||
|
- **[F3] codex NDJSON event shape pin — codex CLI v0.133.0 emits `item.completed` + `turn.completed` + `turn.failed`**, not the D6-assumed `content`/`delta`/`text` + `type:'stop'`/`done:true` shapes. Real v0.133.0 stream (verified empirically): `{"type":"thread.started",...}` → `{"type":"turn.started"}` → `{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}` → `{"type":"turn.completed","usage":{...}}`. Pre-D75, every chunk was silently dropped by `codexChunkToIR()` → response body had `content: null`. Fix: prepend three new recognizers (`item.completed` with `item.type === 'agent_message'` → IR delta; `turn.completed` → IR stop; `turn.failed` → IR error). Legacy D6 defensive recognizers preserved below as forward/backward compat fallbacks.
|
||||||
|
- **[F4] `olp status` reads `body.stats.cache.size` (not OCP-era `body.cache.entries`).** Same class as D74 P2-3 (which fixed `cmdUsage` + `cmdCache`); D74 missed the parallel bug in `cmdStatus`. Server payload nests cache stats as `body.stats.cache.{hits, misses, size, inflightCount}` per `server.mjs handleManagementStatus`, and `CacheStore.stats()` has no `entries` field per `lib/cache/store.mjs`. Pre-D75 output showed `entries=?`. Fix: read `c.size` for entries display; also surface `inflightCount` when present.
|
||||||
|
- **[F7] per-hop chain `model` field now overrides IR model in `provider.spawn()`.** Pre-D75 `executeHopFn(hopProvider, hopModel, irReq)` used `hopModel` for cache key + audit ctx but passed the ORIGINAL `irReq` (with `irReq.model` = user's original request) to `hopProviderPlugin.spawn(irReq, authContext)`. A chain config `[{provider:anthropic, model:claude-X}, {provider:openai, model:gpt-5.5}]` would always spawn BOTH plugins with `--model claude-X` — openai rejected the unknown model and the chain died. This broke the core OLP value prop (cross-provider fallback with provider-appropriate model substitution per hop). Fix: build a per-hop IR variant with `{...irReq, model: hopModel}` and pass that to spawn. Conditional skips clone when `hopModel === irReq.model` (common case: single-provider chains, or single-hop chains where the chain config repeats the request model). Applied to BOTH the buffered path (`executeHopFn`) AND the streaming path (`sourceFactory` for `getOrComputeStreaming`). **Authority:** ADR 0004 § Chain advancement step 1 (per-hop config supplies provider AND model — the contract was always specified, but the code didn't complete it).
|
||||||
|
|
||||||
|
**Phase 5 process learning recorded.** Every provider plugin's D-day must include a real-CLI E2E ON A REMOTE OPERATOR HOST before merging — not on the maintainer workstation (which may have an older CLI cached from a prior install, hiding new field renames / new sandbox flags / new event shapes). The D6/D7 codex E2E was deferred and that deferral compounded across 3 layers (D6 = unpinned, D7 = pinning deferred, D8+ = trusted D6/D7 unchanged). F7 reinforces a separate lesson: when a function signature takes `(provider, model, ir)`, reviewers must check that `model` is consumed everywhere downstream, not just at the call site they happened to look at.
|
||||||
|
|
||||||
|
**Out of D75 scope (deferred to Phase 5 explicit ADR amendments):**
|
||||||
|
- F5 (server bind 127.0.0.1 / `OLP_BIND` env) — needs `lib/keys.mjs` anonymous-key trust boundary review before binding to non-loopback by default
|
||||||
|
- F6 (`olp doctor` client-vs-server-side limit detection) — needs design ADR amendment for trigger taxonomy
|
||||||
|
|
||||||
|
- **Test count delta:** 704 (v0.4.1) → 714 (v0.4.2). +10 D75 regression tests in Suite 36 (36i through 36r).
|
||||||
|
- **Files touched:** `lib/providers/codex.mjs` (F1+F2+F3), `bin/olp.mjs` (F4 cmdStatus), `server.mjs` (F7 buffered + streaming spawn sites), `test-features.mjs` (Suite 36 extension), `package.json` (version), `CHANGELOG.md` (this entry).
|
||||||
|
- **Authority:** ADR 0002 (provider contract — codex plugin), ADR 0004 (fallback engine — per-hop model contract), `lib/providers/codex.mjs` D6 assumption A2/A3/A4 docstrings (which all said "D7 will pin" and D7 never did); codex CLI v0.133.0 on-disk schema + `codex exec --help` output verified empirically on PI231 (2026-05-26 E2E session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline.
|
||||||
|
|
||||||
|
## v0.4.1 — 2026-05-26
|
||||||
|
|
||||||
|
### Post-Phase-4 hotfix batch (D74) — maintainer-review findings
|
||||||
|
|
||||||
|
Patch release fixing 5 issues caught by maintainer post-v0.4.0 independent review. Every finding was a real runtime bug that the per-D-day fresh-context opus reviewers all missed because they reviewed against spec text, not against the runtime contract (default `auth.allow_anonymous: false`, real `/health` payload shape, real `/cache/stats` payload shape, real `/v0/management/dashboard-data` payload shape). **Phase 4 lesson: future implementation D-days MUST include at least one test that boots the server with the default production config and exercises the new feature end-to-end** — not just stub-mocked codepaths.
|
||||||
|
|
||||||
|
- **[P1-1] `olp doctor` no longer false-negatives on auth-required `/health`.** `lib/doctor.mjs` now accepts an `authHeaders` option (threaded from `bin/olp.mjs` `cmdDoctor` via the existing `authHeaders()` chain) and passes it to the `server.running` + `server.version` probes. The `server.running` check now distinguishes 401/403 ("server up but bearer token missing/invalid — set `OLP_API_KEY`") from "server unreachable" — so the `kind` discriminator routes to a clean fix-auth path instead of `fix_server` when the operator just forgot to export the env var.
|
||||||
|
- **[P1-2] `bin/olp-connect` validates token shape + shell-quotes rc writes.** New `validate_olp_token <key> <source>` helper enforces the `^olp_[A-Za-z0-9_-]{43}$` regex (per ADR 0007 § 3 token format) at all 3 input sites: `--key` arg, `/health.anonymousKey` server-advertised consumption, and the interactive prompt fallback. New `shell_quote <value>` helper wraps rc-file writes (`export OPENAI_BASE_URL=$(shell_quote ...)`) so even a hypothetical bypass of the validator can't inject shell metacharacters into a sourced rc. systemd `environment.d/olp.conf` write additionally rejects embedded newlines. Hostile or malformed keys can no longer persist as shell startup injection.
|
||||||
|
- **[P2-3] `olp usage` + `olp cache` human formatter rewritten against the real payload shape.** `cmdUsage` previously read `body.usage_24h.requests` / `body.providers` / `body.top_fallback_chains` — all undefined under the actual server payload shape — so users saw "requests: ?" + missing per-provider quota + missing top-chains. Now reads `body.window_24h.request_count` / `body.cache_hit_24h.hit_rate` / `body.quota` / `body.top_fallback_chains_24h` per `server.mjs:2027` + `lib/audit-query.mjs`. `cmdCache` previously read `body.entries` / `body.bytes` / `body.maxBytes` (OCP-era field names). Now reads `body.size` / `body.inflightCount` per `CacheStore.stats()` and computes hit rate from `hits + misses`.
|
||||||
|
- **[P2-4] `olp-plugin/` `fmtHealth` iterates `providers.status` correctly.** Previously walked `Object.entries(body.providers)` which surfaced `enabled` / `available` / `status` as pseudo-providers (chat output showed `🟢 status` instead of `🟢 anthropic`). Now extracts the real provider map from `body.providers.status` and renders enabled/available counts in a header line + per-provider names with `activeSpawns` when present. Falls back to flat `body.providers.*` for the older OCP shape (backwards compat).
|
||||||
|
- **[P3-5] Stale v0.3.0-era doc strings updated.** README header status line + Implementation Status § now reflect v0.4.0 shipped + Phase 5 open. `server.mjs` startup banner no longer hardcodes "Phase 1 in progress" (now just lists version + provider count — derives accurate state from `VERSION` without future maintenance touch-ups).
|
||||||
|
|
||||||
|
**Phase 4 process learning recorded.** Per Iron Rule 第二律 (evidence over "should work"), every D-day review pass must include at least one runtime smoke against the default production config. The D-day reviewer rubric is updated implicitly — D74 Suite 36 tests pin the wire-contract shape so a future D-day refactoring server payloads can't silently re-break the CLI / plugin / docs.
|
||||||
|
|
||||||
|
- **Test count delta:** 696 (v0.4.0) → 704 (v0.4.1). +8 D74 regression tests in Suite 36.
|
||||||
|
- **Files touched:** `lib/doctor.mjs` (P1-1), `bin/olp.mjs` (P1-1 + P2-3), `bin/olp-connect` (P1-2), `olp-plugin/index.js` (P2-4), `server.mjs` (P3-5 banner), `README.md` (P3-5), `test-features.mjs` (Suite 36 regression), `package.json` (version), `CHANGELOG.md` (this entry).
|
||||||
|
- **Authority:** maintainer independent review of `main` / `v0.4.0` / commit `ee4d945` (2026-05-26 session); Iron Rule 第二律 evidence-over-should-work; CLAUDE.md `release_kit.phase_rolling_mode` cross-Phase discipline ("hotfix to a shipped Phase N deliverable → bump patch, tag, release before next push").
|
||||||
|
|
||||||
## v0.4.0 — 2026-05-26
|
## v0.4.0 — 2026-05-26
|
||||||
|
|
||||||
### Phase 4 — Operator + Client UX (D60 → D73)
|
### Phase 4 — Operator + Client UX (D60 → D73)
|
||||||
|
|||||||
@@ -1,55 +1,204 @@
|
|||||||
# OLP — Open LLM Proxy
|
# OLP — Open LLM Proxy
|
||||||
|
|
||||||
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing, automatic fallback, content-addressed caching — so your IDEs and family clients keep working as long as *any* of your subscriptions has quota left.
|
A personal- and family-scale multi-provider LLM proxy. One HTTP endpoint, many subscriptions behind it, automatic routing + fallback + content-addressed caching. Your IDEs and family clients keep working as long as **any** of your subscriptions has quota left.
|
||||||
|
|
||||||
> **Status:** v0.3.0 shipped (2026-05-25) — Phase 1 multi-provider proxy core (v0.1.0 + v0.1.1) + Phase 2 multi-key auth + audit + owner gating + keygen CLI (v0.2.0) + Phase 3 Dashboard + audit query layer + daily audit rotation (v0.3.0). Phase 4 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. Sections marked _placeholder_ land alongside the relevant phase of work (see [phase plan](#phase-plan)).
|
> **Status:** v0.4.3 shipped, 714+ tests. Phase 4 (Operator + Client UX) closed; Phase 5 scope is open. Coming from [OCP](https://github.com/dtzp555-max/ocp)? See [§ Migration from OCP](#migration-from-ocp).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Why OLP
|
## What you get
|
||||||
|
|
||||||
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. [OCP](https://github.com/dtzp555-max/ocp), OLP's predecessor, was a proxy around a single CLI — its core assumption was *"subscription = unlimited within rate limits"*. That assumption breaks for Anthropic on the effective date.
|
- **OpenAI-compatible** `/v1/chat/completions` endpoint — any IDE that speaks OpenAI (Cline / Continue.dev / Cursor / Aider) plugs in
|
||||||
|
- **Multi-provider chain** — primary fails / quota dies → automatically falls back to the next provider (anthropic ↔ codex ↔ mistral by default; risk-tier framework guards which ones get enabled)
|
||||||
The structural response is to stop relying on one provider's subscription terms remaining favourable. OLP spreads risk across multiple providers whose subscriptions still include CLI/programmatic use, routes intelligently between them, and caches aggressively so every request that does spawn a CLI counts.
|
- **Content-addressed cache** — repeat requests don't re-spawn the CLI; streaming requests dedup via singleflight tee
|
||||||
|
- **Multi-key auth** — owner key with full visibility, family-member keys with per-key audit log + per-provider scoping
|
||||||
OLP is **not**: a commercial multi-tenant SaaS; an enterprise gateway competing with LiteLLM / OpenCode / CLIProxyAPI on breadth; a model-capability router ("route to the smartest model" — you pick the model); a conversation-state store (your client handles that).
|
- **Telegram / Discord** `/olp` slash commands (read-only — for "is OLP up?" checks from anywhere)
|
||||||
|
- **AI-driven self-repair** — `olp doctor --json` emits machine-readable `next_action.ai_executable[]` so a Claude Code / Cursor / Copilot session can fix install issues for you (see [§ Install with your AI](#install-with-your-ai-the-fast-path))
|
||||||
See [`ALIGNMENT.md`](./ALIGNMENT.md) for OLP's constitution and [`docs/adr/`](./docs/adr/) for the founding ADRs.
|
- **Observability** — owner-only `/dashboard` (quota / 24h stats / 30d spend trend / top fallback chains)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Install with your AI (the fast path)
|
||||||
|
|
||||||
_placeholder — lands with Phase 1._
|
If the manual steps feel like a lot, paste this verbatim into your AI coding assistant (Claude Code / Cursor / Copilot / Aider). It walks you through everything:
|
||||||
|
|
||||||
Anticipated shape:
|
```
|
||||||
|
I want to install OLP (Open LLM Proxy) on this machine. The repo is at
|
||||||
|
https://github.com/dtzp555-max/olp and ships these tools that you can use:
|
||||||
|
|
||||||
```bash
|
1. `git clone` the repo to ~/olp
|
||||||
# install
|
2. README.md § "Manual install" has the sequenced steps. Follow them in order.
|
||||||
npm install -g @dtzp555-max/olp
|
3. After EACH step, run `node ~/olp/bin/olp.mjs doctor --json` (NOT `npx olp`
|
||||||
|
— the package isn't on npm). Parse the JSON. The `kind` field tells you
|
||||||
|
the state: noop / fresh_install / fix_server / fix_oauth / fix_provider /
|
||||||
|
fix_config.
|
||||||
|
4. If `kind != noop`, run the commands listed in `next_action.ai_executable[]`
|
||||||
|
verbatim. Then re-run doctor to verify.
|
||||||
|
5. STOP and ask me only when `next_action.human_required[]` is non-empty.
|
||||||
|
That's where I need to do a browser OAuth flow you can't do for me.
|
||||||
|
|
||||||
# run setup (writes ~/.olp/config.json, asks which providers to enable)
|
The provider CLIs OLP spawns (claude / codex / vibe) need their own one-time
|
||||||
olp setup
|
OAuth — those are the only steps I personally have to do (Claude.ai login,
|
||||||
|
ChatGPT login, Mistral API key). Everything else (clone, npm install of the
|
||||||
|
provider CLIs, owner-key generation, config.json bootstrap, server start) is
|
||||||
|
in your `ai_executable[]` and you should run it without asking.
|
||||||
|
|
||||||
# start the proxy (default port 4567 since v0.4.0 — moved off OCP's 3456 so
|
Begin.
|
||||||
# OLP and OCP can co-host on the same machine. Set OLP_PORT=3456 if you have
|
|
||||||
# no OCP on the machine and want the old default.)
|
|
||||||
olp start
|
|
||||||
|
|
||||||
# point your IDE at http://localhost:4567/v1/chat/completions with the OLP API key from `olp keys list`.
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Family-on-LAN onboarding (D68-D70).** For other devices on the same network, run on the client device:
|
Then sit back and respond when it asks for OAuth confirmation. This pattern works because `olp doctor` is purpose-built for AI consumption — every failure mode has a shell-executable repair command AND a human-required step listed separately.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Manual install (5-10 min)
|
||||||
|
|
||||||
|
### 0. Prerequisites
|
||||||
|
|
||||||
|
- **Node.js ≥ 18.** Verify: `node --version`
|
||||||
|
- **The provider CLIs you want OLP to spawn.** Install whichever you'll actually use:
|
||||||
|
|
||||||
|
| Provider | Install | Subscription |
|
||||||
|
|---|---|---|
|
||||||
|
| `anthropic` (`claude -p`) | `npm install -g @anthropic-ai/claude-code` | Claude Pro/Max (OAuth) |
|
||||||
|
| `openai` (`codex exec`) | `npm install -g @openai/codex` | ChatGPT Plus/Pro (OAuth) or OpenAI API key |
|
||||||
|
| `mistral` (`vibe --prompt`) | follow the `vibe` install docs | Le Chat Pro API key |
|
||||||
|
|
||||||
|
You only need to install the ones you'll route to. Single-provider OLP works fine.
|
||||||
|
|
||||||
|
### 1. Clone and verify the test suite
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Detects Cline / Continue.dev / Cursor / Aider / OpenClaw installed locally
|
git clone https://github.com/dtzp555-max/olp.git ~/olp
|
||||||
# and writes per-tool config pointing at the OLP host. Requires `python3`.
|
cd ~/olp
|
||||||
olp-connect <olp-host-ip>
|
npm test # 714+ tests, ~5s, no external deps
|
||||||
```
|
```
|
||||||
|
|
||||||
If the OLP host has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, `olp-connect` picks up the token from `/health.anonymousKey` — zero out-of-band token paste required. See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
|
(If `npm test` fails here, stop — that means your Node version or the repo state is broken. Don't proceed to step 2.)
|
||||||
|
|
||||||
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md).
|
### 2. Bootstrap the owner key
|
||||||
|
|
||||||
|
The owner key is what you (and `olp-connect`) use to authenticate to OLP. Default config has `auth.allow_anonymous: false`, so you need a key BEFORE the server starts accepting requests.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node ~/olp/bin/olp-keys.mjs keygen --owner --name=$(whoami)-laptop
|
||||||
|
# Prints the plaintext token ONCE. Copy it now — you can't recover it later.
|
||||||
|
# Example: olp_l23-PN46tDljmPATV94-KfOgOBO0Ed8theVjTdAgQoY
|
||||||
|
```
|
||||||
|
|
||||||
|
Export it so the CLI subcommands can use it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export OLP_API_KEY=olp_l23-PN46... # paste your real token
|
||||||
|
```
|
||||||
|
|
||||||
|
(Add to `~/.bashrc` / `~/.zshrc` to persist.)
|
||||||
|
|
||||||
|
### 3. Authenticate the providers (one-time OAuth)
|
||||||
|
|
||||||
|
Run each provider's own login flow. OLP's anthropic / openai / mistral plugins spawn these CLIs and reuse their cached credentials — OLP itself never touches the OAuth dance.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Anthropic (Claude Pro/Max subscription)
|
||||||
|
claude setup-token
|
||||||
|
# Opens a TUI / prints a URL. Authorize in browser. Paste the returned code.
|
||||||
|
# Result: ~/.claude/.credentials.json
|
||||||
|
|
||||||
|
# OpenAI (ChatGPT subscription)
|
||||||
|
codex login --device-auth
|
||||||
|
# Prints a https://auth.openai.com/codex/device URL + 10-char code.
|
||||||
|
# Open URL in browser, enter code, authorize.
|
||||||
|
# Result: ~/.codex/auth.json
|
||||||
|
|
||||||
|
# Mistral (Le Chat API key)
|
||||||
|
export MISTRAL_API_KEY=sk-... # add to ~/.bashrc to persist
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Write a minimum config
|
||||||
|
|
||||||
|
`~/.olp/config.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"auth": {
|
||||||
|
"allow_anonymous": false,
|
||||||
|
"owner_only_endpoints": [
|
||||||
|
"/health",
|
||||||
|
"/v0/management/dashboard-data",
|
||||||
|
"/v0/management/quota",
|
||||||
|
"/v0/management/status",
|
||||||
|
"/cache/stats",
|
||||||
|
"/dashboard"
|
||||||
|
],
|
||||||
|
"fallback_detail_header_policy": "owner_only"
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"enabled": { "anthropic": true, "openai": true }
|
||||||
|
},
|
||||||
|
"routing": {
|
||||||
|
"chains": {
|
||||||
|
"claude-sonnet-4-6": [
|
||||||
|
{ "provider": "anthropic", "model": "claude-sonnet-4-6" },
|
||||||
|
{ "provider": "openai", "model": "gpt-5.5" }
|
||||||
|
],
|
||||||
|
"gpt-5.5": [{ "provider": "openai", "model": "gpt-5.5" }]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"streaming": { "heartbeat_interval_ms": 15000 }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
(Enable only the providers you actually authenticated in step 3. Chains map `<your-IDE's-requested-model>` → ordered list of `{provider, model}` hops; the chain's per-hop `model` is what gets passed to that provider's CLI.)
|
||||||
|
|
||||||
|
### 5. Start the server
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd ~/olp
|
||||||
|
npm start
|
||||||
|
# OLP v0.4.3 listening on :4567 (2 providers enabled)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. Smoke-test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -H "Authorization: Bearer $OLP_API_KEY" http://localhost:4567/health | jq
|
||||||
|
# Expect: {ok: true, providers: {enabled: 2, status: {anthropic: {ok: true...}, openai: {ok: true...}}}}
|
||||||
|
|
||||||
|
node ~/olp/bin/olp.mjs doctor
|
||||||
|
# Expect: "9 of 9 checks passed", kind=noop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 7. Point your IDE at OLP
|
||||||
|
|
||||||
|
```
|
||||||
|
OPENAI_BASE_URL=http://localhost:4567/v1
|
||||||
|
OPENAI_API_KEY=$OLP_API_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
Per-IDE configuration details: [`docs/integrations/`](./docs/integrations/README.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Family / LAN setup
|
||||||
|
|
||||||
|
To let other devices on your home network use the same OLP server, you need TWO things:
|
||||||
|
|
||||||
|
1. **Bind to the LAN interface** (not just loopback). On the SERVER:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
OLP_BIND=0.0.0.0 npm start # or your specific LAN IP, e.g. 192.168.1.10
|
||||||
|
```
|
||||||
|
|
||||||
|
Default is `127.0.0.1` (loopback only). See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — **never set `OLP_BIND=0.0.0.0` on a public-internet-facing host** (use a tunnel like Tailscale instead).
|
||||||
|
|
||||||
|
2. **Onboard each family member's device** from THEIR machine:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash <(curl -fsSL https://raw.githubusercontent.com/dtzp555-max/olp/main/bin/olp-connect) <olp-host-ip>
|
||||||
|
```
|
||||||
|
|
||||||
|
Detects Cline / Continue.dev / Cursor / Aider / OpenClaw locally and writes per-tool config pointing at your OLP host. Requires `python3` on the client. Prompts for the OLP API key — OR, if the server has `auth.advertise_anonymous_key: true` AND a key was created with `olp-keys keygen --anonymous --advertise`, picks the token up from `/health.anonymousKey` (zero out-of-band paste). See [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md) for the trusted-LAN-only invariant.
|
||||||
|
|
||||||
|
Per-IDE setup details: [`docs/integrations/`](./docs/integrations/README.md). Telegram / Discord `/olp` slash command setup: [§ Telegram / Discord Usage](#telegram--discord-usage).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -80,12 +229,19 @@ OLP distinguishes **Candidate Providers** (declared as intended, not yet pinned)
|
|||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
_placeholder — full configuration reference lands with Phase 4 (fallback engine)._
|
OLP reads `~/.olp/config.json` at startup. § "[Manual install § Step 4](#4-write-a-minimum-config)" above has a working minimum example. The full schema:
|
||||||
|
|
||||||
OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
|
"auth": {
|
||||||
|
"allow_anonymous": false,
|
||||||
|
"owner_only_endpoints": ["/health", "/dashboard", "/v0/management/..."],
|
||||||
|
"advertise_anonymous_key": false,
|
||||||
|
"fallback_detail_header_policy": "owner_only"
|
||||||
|
},
|
||||||
|
"providers": {
|
||||||
|
"enabled": { "<provider-key>": true }
|
||||||
|
},
|
||||||
"routing": {
|
"routing": {
|
||||||
"chains": {
|
"chains": {
|
||||||
"<requested-model>": [
|
"<requested-model>": [
|
||||||
@@ -96,13 +252,25 @@ OLP reads its config from `~/.olp/config.json`. The minimum useful shape:
|
|||||||
"soft_triggers": {
|
"soft_triggers": {
|
||||||
"<provider-key>": { "<trigger>": <threshold> }
|
"<provider-key>": { "<trigger>": <threshold> }
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"streaming": {
|
||||||
|
"heartbeat_interval_ms": 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Note:** `routing.soft_triggers` thresholds are parsed and stored but have **no runtime effect at v0.1** — the quota polling path (`quotaStatus()` per hop) is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). The evaluation logic exists and is tested; only the production data ingestion path is deferred.
|
Field guide:
|
||||||
|
|
||||||
Trigger types, fallback safety, idempotency rules, and the full example config land here when Phase 4 ships. See [ADR 0004 (Fallback Engine Semantics & Safety)](./docs/adr/0004-fallback-engine.md) for the design.
|
- **`auth.allow_anonymous`** — default `false`. When false, every request needs a Bearer token; when true, anonymous-tier requests succeed (ADR 0007 § 7). Production posture is `false`.
|
||||||
|
- **`auth.owner_only_endpoints`** — list of endpoints that REQUIRE owner-tier auth (non-owner returns 401). The defaults above are minimum sane for production.
|
||||||
|
- **`auth.advertise_anonymous_key`** — default `false`. When true (+ `allow_anonymous: true` + a key created with `olp-keys keygen --anonymous --advertise`), `/health.anonymousKey` exposes the plaintext token so `olp-connect <ip>` is zero-config. **Trusted-LAN only** — see [ADR 0011](./docs/adr/0011-anonymous-key-deployment-context.md).
|
||||||
|
- **`auth.fallback_detail_header_policy`** — controls `X-OLP-Fallback-Detail` response header emission. `owner_only` (default) only shows tuples to owner identity; debug surface to LAN family without leaking to anonymous.
|
||||||
|
- **`providers.enabled`** — flip a provider plugin on. Only enable providers whose CLI you've authenticated; OLP doesn't do its own OAuth.
|
||||||
|
- **`routing.chains`** — keyed by the model name your IDE / client requests. Each entry is an ordered list of fallback hops; each hop's `model` is what gets passed to that provider's CLI. F7 fix (D75) — the hop-level `model` field finally overrides the IR's request model during cross-provider fallback.
|
||||||
|
- **`routing.soft_triggers`** — parsed and stored but **inert at v0.4.x** — the `quotaStatus()` polling data path is deferred to v1.x per [ADR 0004 Amendment 2](./docs/adr/0004-fallback-engine.md#amendment-2--2026-05-24-soft-triggers-deferred-to-v1x-d22). Startup emits a warn if non-empty so the inert state is visible.
|
||||||
|
- **`streaming.heartbeat_interval_ms`** — default `0` (disabled). Set > 0 (e.g. `15000`) to emit SSE keepalive frames during silent windows. Required behind reverse proxies (nginx / Cloudflare Tunnel / Tailscale Funnel) with 60s idle aborts.
|
||||||
|
|
||||||
|
See [ADR 0004 (Fallback Engine)](./docs/adr/0004-fallback-engine.md), [ADR 0007 (Multi-key auth)](./docs/adr/0007-multi-key-auth.md), [ADR 0010 (Phase 4 charter)](./docs/adr/0010-phase-4-charter-operator-and-client-ux.md), [ADR 0011 (Anonymous-key deployment)](./docs/adr/0011-anonymous-key-deployment-context.md).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -127,6 +295,10 @@ _placeholder — full table lands per-phase as variables are introduced._
|
|||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. |
|
| `OLP_PORT` | `4567` | HTTP listener port. Moved off `3456` at D60 / v0.4.0 to co-host with OCP — set `OLP_PORT=3456` to restore the pre-D60 default. |
|
||||||
|
| `OLP_BIND` | `127.0.0.1` | HTTP listener bind address. **Set to `0.0.0.0` or your LAN IP to accept LAN connections** (required for `olp-connect <ip>` to actually reach the server). Default loopback-only is the secure default. See [ADR 0011 § Deployment configurations](./docs/adr/0011-anonymous-key-deployment-context.md#deployment-configurations-d76-amendment-2026-05-26) for the trust-context table — never bind to a public-internet IP. |
|
||||||
|
| `OLP_API_KEY` | (none) | Owner-tier OLP API key (the `olp_...` plaintext from `olp-keys keygen --owner`) used by `olp` CLI subcommands as the bearer for management endpoints. |
|
||||||
|
| `OLP_OWNER_TOKEN` | (none) | Fallback used by `olp` CLI if `OLP_API_KEY` is absent. |
|
||||||
|
| `OLP_PROXY_URL` | `http://127.0.0.1:$OLP_PORT` | Override target URL for `olp` CLI subcommands (so the same binary works against a remote OLP via SSH tunnel or direct LAN). |
|
||||||
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
|
| `OLP_CLAUDE_BIN` | `claude` (from PATH) | Override path to the `claude` binary (Anthropic provider). Useful when multiple `claude` installs are present. |
|
||||||
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
|
| `OLP_CODEX_BIN` | `codex` (from PATH) | Override path to the `codex` binary (OpenAI provider). |
|
||||||
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
|
| `OLP_VIBE_BIN` | `vibe` (from PATH) | Override path to the `vibe` binary (Mistral provider). |
|
||||||
@@ -252,9 +424,9 @@ Use a dedicated bot key — not the maintainer's personal owner key — so revoc
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Implementation status (as of 2026-05-25, post-v0.2.0)
|
## Implementation status (as of 2026-05-26, post-v0.4.0)
|
||||||
|
|
||||||
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 (per-key per-provider auth + audit retention + SQLite hybrid + provider-cost weights) is the next planned milestone. This table reflects what is currently shipped vs. what is designed for later phases.
|
Phase 1 closed at v0.1.1 (multi-provider proxy core + pre-Phase-2 cleanup). Phase 2 closed at v0.2.0 (multi-key auth + audit + owner gating + keygen CLI; ADR 0007 § 10 all 11 acceptance criteria shipped). Phase 3 closed at v0.3.0 (Dashboard + `lib/audit-query.mjs` + daily audit rotation; ADR 0008 § 10 all 15 acceptance criteria shipped). Phase 4 closed at v0.4.0 (Operator + Client UX per ADR 0010: SSE heartbeat + `recentErrors[20]` + `/v0/management/status` / `olp` Node CLI + `olp doctor` framework + ADR 0002 Amendment 7 / `olp-connect` bash + `/health.anonymousKey` + ADR 0011 / `olp-plugin/` Telegram-Discord + 6-IDE integration docs). Phase 5 scope is open — candidates per ADR 0010 § Out-of-Phase-4-scope. This table reflects what is currently shipped vs. what is designed for later phases.
|
||||||
|
|
||||||
| File / artifact | Status | Notes |
|
| File / artifact | Status | Notes |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -289,7 +461,9 @@ Behaviors that work correctly at personal/family scale but have ratified follow-
|
|||||||
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
- **Soft triggers configured but inert.** `routing.soft_triggers` in `~/.olp/config.json` is honored by the engine's evaluation logic but `quotaStatus()` polling is not wired (ADR 0004 Amendment 2). A startup warning fires if the field is non-empty so the inert state is visible.
|
||||||
- **Multi-key auth + owner gating + keygen CLI shipped at v0.2.0 (D44 + D45 + D46 + D47).** `lib/keys.mjs` (core), `lib/audit.mjs` (audit), owner-vs-guest `/health` payload trimming + `X-OLP-Fallback-Detail` policy gating, `bin/olp-keys.mjs` (keygen CLI). All 11 ADR 0007 § 10 acceptance criteria covered. v0.2.0 maintainer-merged 2026-05-25.
|
- **Multi-key auth + owner gating + keygen CLI shipped at v0.2.0 (D44 + D45 + D46 + D47).** `lib/keys.mjs` (core), `lib/audit.mjs` (audit), owner-vs-guest `/health` payload trimming + `X-OLP-Fallback-Detail` policy gating, `bin/olp-keys.mjs` (keygen CLI). All 11 ADR 0007 § 10 acceptance criteria covered. v0.2.0 maintainer-merged 2026-05-25.
|
||||||
|
|
||||||
- **Phase 3 (Dashboard + audit query layer + rotation) shipped to main (D48-D54); v0.3.0 release pending.** `docs/adr/0008-dashboard-and-audit-query.md` ratified at D48. `lib/audit-query.mjs` (D49) implements the 5-function aggregate query API (in-memory ndjson scan, PII-guarded). 4 new owner-only_block endpoints at D50 (`/dashboard`, `/v0/management/dashboard-data`, `/v0/management/quota`, `/cache/stats`). `dashboard.html` full multi-panel UI at D51 (vanilla HTML+JS+fetch, 30s poll with visibilitychange pause). Daily audit rotation at D52 (synchronous on first append after UTC midnight; `audit-YYYY-MM-DD.ndjson` naming) + optional `bin/olp-audit-rotate.mjs` cron tool. `tried_providers` schema semantic fix at D53 (D45 P2 deferral). Phase 3 close to v0.3.0 is maintainer-triggered per CLAUDE.md `release_kit.phase_close_trigger`.
|
- **Phase 3 (Dashboard + audit query layer + rotation) shipped at v0.3.0 (D48–D54).** `docs/adr/0008-dashboard-and-audit-query.md` + `lib/audit-query.mjs` (D49) + 4 owner-only_block endpoints (D50) + `dashboard.html` (D51) + daily audit rotation (D52) + `tried_providers` schema fix (D53). All 15 ADR 0008 § 10 acceptance criteria covered.
|
||||||
|
|
||||||
|
- **Phase 4 (Operator + Client UX) shipped at v0.4.0 (D60 → D73).** ADR 0010 (charter) + ADR 0011 (anonymous-key trusted-LAN limits) + ADR 0002 Amendment 7 (provider `doctorChecks()` contract). Default `OLP_PORT` 3456 → 4567 so OLP and OCP can co-host. SSE heartbeat (D61) + `recentErrors[20]` + `/v0/management/status` (D62-D63). `bin/olp.mjs` Node CLI + `bin/olp-keys.mjs` + `lib/doctor.mjs` framework with `next_action.ai_executable[]` (D64-D67). `bin/olp-connect` bash zero-config IDE auto-config + opt-in `/health.anonymousKey` (D68-D70). `olp-plugin/` OpenClaw `/olp` Telegram-Discord plugin (read-only, no chat mutations) + 6 IDE integration docs at `docs/integrations/*.md` (D71-D73). Test count 623 → 696.
|
||||||
|
|
||||||
**Bootstrap workflow (D47):** for first-run / production setup:
|
**Bootstrap workflow (D47):** for first-run / production setup:
|
||||||
|
|
||||||
@@ -351,16 +525,22 @@ Full spec (decision rationale, open questions, risks): `~/.cc-rules/memory/proje
|
|||||||
|
|
||||||
## Migration from OCP
|
## Migration from OCP
|
||||||
|
|
||||||
|
OLP is OCP's successor. The trigger was Anthropic's 2026-05-14 announcement (effective 2026-06-15) splitting `claude -p` / Agent SDK / third-party agent traffic out of the Pro/Max subscription pool into a separate fixed $100/month Agent SDK credit pool — invalidating OCP's foundational assumption (*"subscription = unlimited within rate limits"*) for its only provider. OLP's structural response is to spread risk across multiple subscriptions whose CLI/programmatic use remains in their main subscription pool, with intelligent fallback when one runs out.
|
||||||
|
|
||||||
|
Beyond the billing trigger, OLP is intentionally NOT a commercial multi-tenant SaaS (LiteLLM / OpenRouter / Portkey already serve that market with funding + SOC2), NOT an enterprise gateway competing on provider breadth, NOT a model-capability router ("route to the smartest model" — you pick the model in `routing.chains`), and NOT a conversation-state store (your client manages its own context). See [ADR 0001](./docs/adr/0001-project-founding.md) for the founding decision and [`ALIGNMENT.md`](./ALIGNMENT.md) for the constitution that governs every plugin / IR / entry-surface change.
|
||||||
|
|
||||||
|
### Migrating an existing OCP install
|
||||||
|
|
||||||
_placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._
|
_placeholder — `scripts/migrate-from-ocp.mjs` lands with Phase 7 (📋 planned, not yet authored)._
|
||||||
|
|
||||||
Anticipated user-facing flow (target: <5 minutes):
|
Anticipated user-facing flow (target: <5 minutes):
|
||||||
|
|
||||||
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
|
1. Stop OCP (`launchctl bootout` the OCP service or `ocp stop`).
|
||||||
2. Install OLP.
|
2. Install OLP (per [§ Manual install](#manual-install-5-10-min) above).
|
||||||
3. Run `olp migrate-from-ocp` — copies `~/.ocp/keys/` to `~/.olp/keys/` and points provider plugins at OCP's existing auth artifacts where applicable.
|
3. Run `olp migrate-from-ocp` — will copy `~/.ocp/keys/` to `~/.olp/keys/` and point provider plugins at OCP's existing auth artifacts where applicable.
|
||||||
4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid. **Note (v0.4.0+):** default port moved from 3456 → 4567 so OCP and OLP can co-host during migration; set `OLP_PORT=3456` if you want the pre-D60 default.
|
4. Start OLP. Clients pointing at port 4567 (or 3456 with `OLP_PORT=3456`) keep working; their existing OLP API keys remain valid.
|
||||||
|
|
||||||
OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
|
**Default port moved 3456 → 4567 at v0.4.0** so OCP and OLP can co-host on the same machine during the migration window — set `OLP_PORT=3456` if you want the pre-D60 default. OCP's cache directory is *not* migrated: OLP's cache key format includes provider+model and warms cold naturally. OCP enters maintenance mode (stability fixes only) when OLP v0.1 ships; new development happens in OLP.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+59
-5
@@ -114,6 +114,35 @@ key_display() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# D74 P1-2: validate OLP API key format. Per ADR 0007 § 3, tokens are
|
||||||
|
# `olp_` + 32 random bytes base64url-encoded (43 chars, no padding). This
|
||||||
|
# regex pins the on-the-wire shape so a malformed or hostile `--key` /
|
||||||
|
# server-advertised `anonymousKey` never gets persisted into a shell rc.
|
||||||
|
# Returns 0 on valid, 1 on invalid (with diagnostic to stderr).
|
||||||
|
validate_olp_token() {
|
||||||
|
local k="$1" source="$2"
|
||||||
|
if [[ ! "$k" =~ ^olp_[A-Za-z0-9_-]{43}$ ]]; then
|
||||||
|
log_err "Rejected $source: token format does not match ^olp_[A-Za-z0-9_-]{43}$ (ADR 0007 § 3)."
|
||||||
|
log_err " Got ${#k}-char value starting with '$(echo "$k" | cut -c1-8)...'"
|
||||||
|
log_err " Expected: olp_ followed by 43 base64url chars. Run 'npx olp-keys list' on the server"
|
||||||
|
log_err " to confirm the key format, or have the operator regenerate with 'npx olp-keys keygen'."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# D74 P1-2: POSIX shell-quote a value before interpolating into a shell rc
|
||||||
|
# write. Wraps in single quotes + escapes embedded single quotes per:
|
||||||
|
# foo'bar → 'foo'\''bar'
|
||||||
|
# Even with the validator above, this is defense-in-depth: any non-token
|
||||||
|
# string that slips through (e.g., environment.d KEY=VALUE writes) MUST be
|
||||||
|
# safe to source. Same helper pattern as lib/doctor.mjs _shellQuote.
|
||||||
|
shell_quote() {
|
||||||
|
local s="$1"
|
||||||
|
# Escape any single quotes: ' → '\''
|
||||||
|
printf "'%s'" "${s//\'/\'\\\'\'}"
|
||||||
|
}
|
||||||
|
|
||||||
# Detect Claude Code and print warn-only message. Per ADR 0010 § Out of
|
# Detect Claude Code and print warn-only message. Per ADR 0010 § Out of
|
||||||
# Phase 4 scope, OLP does NOT ship /v1/messages and CC is not a supported
|
# Phase 4 scope, OLP does NOT ship /v1/messages and CC is not a supported
|
||||||
# client. The user is steered toward Cline + OLP.
|
# client. The user is steered toward Cline + OLP.
|
||||||
@@ -297,17 +326,20 @@ append_olp_block() {
|
|||||||
if $DRY_RUN; then
|
if $DRY_RUN; then
|
||||||
log_change "[dry-run] would append OLP block to $rc_file:"
|
log_change "[dry-run] would append OLP block to $rc_file:"
|
||||||
log_change " # OLP LAN (added by olp-connect)"
|
log_change " # OLP LAN (added by olp-connect)"
|
||||||
log_change " export OPENAI_BASE_URL=$base_url/v1"
|
log_change " export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
|
||||||
[[ -n "$key" ]] && log_change " export OPENAI_API_KEY=$(key_display "$key")"
|
[[ -n "$key" ]] && log_change " export OPENAI_API_KEY=$(shell_quote "$(key_display "$key")")"
|
||||||
log_change " # /OLP LAN"
|
log_change " # /OLP LAN"
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
|
# D74 P1-2: shell-quote values before writing to rc files. Defense-in-depth
|
||||||
|
# alongside validate_olp_token — even if a future code path bypasses the
|
||||||
|
# validator, the rc file remains safe to source.
|
||||||
{
|
{
|
||||||
echo ""
|
echo ""
|
||||||
echo "# OLP LAN (added by olp-connect)"
|
echo "# OLP LAN (added by olp-connect)"
|
||||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
echo "export OPENAI_BASE_URL=$(shell_quote "$base_url/v1")"
|
||||||
if [[ -n "$key" ]]; then
|
if [[ -n "$key" ]]; then
|
||||||
echo "export OPENAI_API_KEY=$key"
|
echo "export OPENAI_API_KEY=$(shell_quote "$key")"
|
||||||
fi
|
fi
|
||||||
echo "# /OLP LAN"
|
echo "# /OLP LAN"
|
||||||
} >> "$rc_file"
|
} >> "$rc_file"
|
||||||
@@ -341,6 +373,14 @@ set_system_env() {
|
|||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
mkdir -p "$env_dir" 2>/dev/null
|
mkdir -p "$env_dir" 2>/dev/null
|
||||||
|
# D74 P1-2: systemd environment.d format is KEY=VALUE per line. While
|
||||||
|
# systemd does its own parsing (no shell sourcing), reject embedded
|
||||||
|
# newlines defensively — validate_olp_token already enforces the
|
||||||
|
# restricted charset for the API key, so this is belt-and-braces.
|
||||||
|
if [[ "$base_url" == *$'\n'* || "$key" == *$'\n'* ]]; then
|
||||||
|
log_err "Refusing to write environment.d entry: value contains newline."
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
{
|
{
|
||||||
echo "OPENAI_BASE_URL=$base_url/v1"
|
echo "OPENAI_BASE_URL=$base_url/v1"
|
||||||
if [[ -n "$key" ]]; then
|
if [[ -n "$key" ]]; then
|
||||||
@@ -363,8 +403,13 @@ main() {
|
|||||||
--port=*) port="${1#*=}"; shift ;;
|
--port=*) port="${1#*=}"; shift ;;
|
||||||
--key) key="${2:?--key requires a value}"
|
--key) key="${2:?--key requires a value}"
|
||||||
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
|
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
|
||||||
|
# D74 P1-2: reject malformed --key before it ever reaches an rc write.
|
||||||
|
validate_olp_token "$key" "--key flag" || exit 1
|
||||||
shift 2 ;;
|
shift 2 ;;
|
||||||
--key=*) key="${1#*=}"; shift ;;
|
--key=*) key="${1#*=}"
|
||||||
|
[[ -z "$key" ]] && { log_err "--key cannot be empty (omit --key for zero-config / auto-discovery)"; exit 1; }
|
||||||
|
validate_olp_token "$key" "--key flag" || exit 1
|
||||||
|
shift ;;
|
||||||
--no-system-env) NO_SYSTEM_ENV=true; shift ;;
|
--no-system-env) NO_SYSTEM_ENV=true; shift ;;
|
||||||
--dry-run) DRY_RUN=true; shift ;;
|
--dry-run) DRY_RUN=true; shift ;;
|
||||||
--version) show_version; exit 0 ;;
|
--version) show_version; exit 0 ;;
|
||||||
@@ -457,6 +502,13 @@ try:
|
|||||||
print(k if isinstance(k, str) and k else '')
|
print(k if isinstance(k, str) and k else '')
|
||||||
except: print('')" 2>/dev/null || echo "")
|
except: print('')" 2>/dev/null || echo "")
|
||||||
if [[ -n "$anon_key" ]]; then
|
if [[ -n "$anon_key" ]]; then
|
||||||
|
# D74 P1-2: validate server-advertised token shape before consuming.
|
||||||
|
# A hostile or misconfigured server could otherwise inject arbitrary
|
||||||
|
# strings into the user's rc file via the `anonymousKey` field.
|
||||||
|
if ! validate_olp_token "$anon_key" "/health.anonymousKey from $remote_host"; then
|
||||||
|
log_err "Refusing to consume malformed advertised key. Use --key explicitly or contact the OLP operator."
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
key="$anon_key"
|
key="$anon_key"
|
||||||
log_ok "Using server-advertised anonymous key: $(key_display "$key")"
|
log_ok "Using server-advertised anonymous key: $(key_display "$key")"
|
||||||
log_info " (set by remote via auth.advertise_anonymous_key=true; see ADR 0011 for"
|
log_info " (set by remote via auth.advertise_anonymous_key=true; see ADR 0011 for"
|
||||||
@@ -479,6 +531,8 @@ except: print('')" 2>/dev/null || echo "")
|
|||||||
log_err " Re-run with: olp-connect $host --key olp_..."
|
log_err " Re-run with: olp-connect $host --key olp_..."
|
||||||
exit 2
|
exit 2
|
||||||
fi
|
fi
|
||||||
|
# D74 P1-2: also validate the interactively-prompted key.
|
||||||
|
validate_olp_token "$key" "interactive prompt" || exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|||||||
+58
-18
@@ -251,9 +251,15 @@ async function cmdStatus(flags, io) {
|
|||||||
}
|
}
|
||||||
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
|
io.log(` total reqs: ${body.stats?.total_requests ?? 0}`);
|
||||||
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
|
io.log(` active reqs: ${body.stats?.active_requests ?? 0}`);
|
||||||
|
// D75 F4 fix: server payload nests cache stats under stats.cache (per
|
||||||
|
// server.mjs handleManagementStatus, ~line 2092). The CacheStore.stats()
|
||||||
|
// contract returns { hits, misses, size, inflightCount } per
|
||||||
|
// lib/cache/store.mjs — there is no `entries` field. Pre-D75 cmdStatus read
|
||||||
|
// `body.stats.cache.entries` (OCP-era) which was always undefined → output
|
||||||
|
// showed "entries=?". Same pattern as D74 P2-3 applied to cmdCache/cmdUsage.
|
||||||
if (body.stats?.cache) {
|
if (body.stats?.cache) {
|
||||||
const c = body.stats.cache;
|
const c = body.stats.cache;
|
||||||
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.entries ?? '?'}`);
|
io.log(` cache: hits=${c.hits ?? 0} misses=${c.misses ?? 0} entries=${c.size ?? 0}${typeof c.inflightCount === 'number' ? ` inflight=${c.inflightCount}` : ''}`);
|
||||||
}
|
}
|
||||||
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
if (Array.isArray(body.recent_errors) && body.recent_errors.length > 0) {
|
||||||
io.log(` recent errors: ${body.recent_errors.length}`);
|
io.log(` recent errors: ${body.recent_errors.length}`);
|
||||||
@@ -301,29 +307,50 @@ async function cmdUsage(flags, io) {
|
|||||||
try { body = JSON.parse(res.body); }
|
try { body = JSON.parse(res.body); }
|
||||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||||
|
// D74 P2-3 fix: server payload shape is { generated_at, window_24h: { request_count, status_2xx,
|
||||||
|
// status_4xx, status_5xx, by_provider, by_owner_tier, by_path, median_latency_ms, p95_latency_ms },
|
||||||
|
// cache_hit_24h: { total, hit, miss, bypass, streaming_attached, hit_rate, by_provider }, quota: [{provider, ...}],
|
||||||
|
// spend_trend_30d: [{date, request_count, by_provider}], top_fallback_chains_24h: [{chain, count, ...}],
|
||||||
|
// cache_stats: { hits, misses, size, inflightCount } } per server.mjs:2027 + lib/audit-query.mjs.
|
||||||
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
|
io.log(colorize('OLP usage (24h)', ANSI.bold, io.useColor));
|
||||||
io.log('─'.repeat(60));
|
io.log('─'.repeat(60));
|
||||||
const u24 = body.usage_24h ?? body.usage24h ?? body['24h'] ?? {};
|
const w24 = body.window_24h ?? {};
|
||||||
if (typeof u24 === 'object' && Object.keys(u24).length > 0) {
|
const cache24 = body.cache_hit_24h ?? {};
|
||||||
io.log(` requests: ${u24.requests ?? '?'}`);
|
if (typeof w24 === 'object' && (w24.request_count ?? 0) > 0) {
|
||||||
io.log(` cache hits: ${u24.cache_hits ?? '?'}`);
|
io.log(` requests: ${w24.request_count}`);
|
||||||
io.log(` fallbacks: ${u24.fallbacks ?? '?'}`);
|
io.log(` 2xx / 4xx / 5xx: ${w24.status_2xx ?? 0} / ${w24.status_4xx ?? 0} / ${w24.status_5xx ?? 0}`);
|
||||||
|
if (typeof w24.median_latency_ms === 'number') {
|
||||||
|
io.log(` latency p50/p95: ${w24.median_latency_ms}ms / ${w24.p95_latency_ms ?? 0}ms`);
|
||||||
|
}
|
||||||
|
if (typeof cache24.hit_rate === 'number') {
|
||||||
|
const pct = (cache24.hit_rate * 100).toFixed(1);
|
||||||
|
io.log(` cache hit rate: ${pct}% (hit=${cache24.hit ?? 0} miss=${cache24.miss ?? 0}${cache24.streaming_attached ? ` streaming_attached=${cache24.streaming_attached}` : ''})`);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
io.log(' (no 24h usage data — server may not have processed any requests yet)');
|
io.log(' (no 24h usage data — server may not have processed any requests yet)');
|
||||||
}
|
}
|
||||||
if (Array.isArray(body.providers)) {
|
if (Array.isArray(body.quota) && body.quota.length > 0) {
|
||||||
io.log('');
|
io.log('');
|
||||||
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
|
io.log(colorize('Per-provider quota', ANSI.bold, io.useColor));
|
||||||
io.log('─'.repeat(60));
|
io.log('─'.repeat(60));
|
||||||
for (const p of body.providers) {
|
for (const p of body.quota) {
|
||||||
io.log(` ${String(p.name ?? '?').padEnd(12)} ${p.percent_used != null ? `${p.percent_used}% used` : 'no quota api'}`);
|
const label = String(p.provider ?? '?').padEnd(12);
|
||||||
|
if (p.error) {
|
||||||
|
io.log(` ${label} error: ${p.error}`);
|
||||||
|
} else if (typeof p.percent_used === 'number') {
|
||||||
|
io.log(` ${label} ${p.percent_used}% used${p.resets_in_human ? ` (resets in ${p.resets_in_human})` : ''}`);
|
||||||
|
} else if (p.available === false) {
|
||||||
|
io.log(` ${label} unavailable`);
|
||||||
|
} else {
|
||||||
|
io.log(` ${label} no quota api`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (Array.isArray(body.top_fallback_chains)) {
|
}
|
||||||
|
if (Array.isArray(body.top_fallback_chains_24h) && body.top_fallback_chains_24h.length > 0) {
|
||||||
io.log('');
|
io.log('');
|
||||||
io.log(colorize('Top fallback chains', ANSI.bold, io.useColor));
|
io.log(colorize('Top fallback chains (24h)', ANSI.bold, io.useColor));
|
||||||
io.log('─'.repeat(60));
|
io.log('─'.repeat(60));
|
||||||
for (const f of body.top_fallback_chains.slice(0, 10)) {
|
for (const f of body.top_fallback_chains_24h.slice(0, 10)) {
|
||||||
io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`);
|
io.log(` ${String(f.count ?? '?').padStart(5)} ${(f.chain ?? []).join(' → ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -360,13 +387,20 @@ async function cmdCache(flags, io) {
|
|||||||
try { body = JSON.parse(res.body); }
|
try { body = JSON.parse(res.body); }
|
||||||
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
catch { io.errln('Error: server returned non-JSON body'); return 2; }
|
||||||
if (io.wantJson) { io.emitJson(body); return 0; }
|
if (io.wantJson) { io.emitJson(body); return 0; }
|
||||||
io.log(colorize('OLP cache', ANSI.bold, io.useColor));
|
// D74 P2-3 fix: cacheStore.stats() returns { hits, misses, size, inflightCount }
|
||||||
|
// per lib/cache/store.mjs:320. There is no entries / evictions / bytes / maxBytes
|
||||||
|
// in the OLP cache model — those were OCP-era field names. Compute a hit rate from
|
||||||
|
// the numerator/denominator instead of fabricating bytes.
|
||||||
|
const hits = body.hits ?? 0;
|
||||||
|
const misses = body.misses ?? 0;
|
||||||
|
const denom = hits + misses;
|
||||||
|
const hitRate = denom > 0 ? ((hits / denom) * 100).toFixed(1) : '0.0';
|
||||||
|
io.log(colorize('OLP cache (live in-memory)', ANSI.bold, io.useColor));
|
||||||
io.log('─'.repeat(60));
|
io.log('─'.repeat(60));
|
||||||
io.log(` entries: ${body.entries ?? '?'}`);
|
io.log(` entries: ${body.size ?? 0}`);
|
||||||
io.log(` hits: ${body.hits ?? 0}`);
|
io.log(` hits / misses: ${hits} / ${misses} (hit rate ${hitRate}%)`);
|
||||||
io.log(` misses: ${body.misses ?? 0}`);
|
io.log(` inflight: ${body.inflightCount ?? 0}`);
|
||||||
io.log(` evictions:${body.evictions ?? 0}`);
|
if (body.generated_at) io.log(` generated_at: ${body.generated_at}`);
|
||||||
io.log(` bytes: ${formatBytes(body.bytes ?? 0)} (max ${formatBytes(body.maxBytes ?? 0)})`);
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,10 +608,16 @@ async function cmdDoctor(flags, io) {
|
|||||||
const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
const proxyUrl = resolveProxyUrl({ proxyUrl: flags['proxy-url'] });
|
||||||
const checkFilter = typeof flags.check === 'string' ? flags.check : undefined;
|
const checkFilter = typeof flags.check === 'string' ? flags.check : undefined;
|
||||||
|
|
||||||
|
// D74 P1-1: pass authHeaders so server.running / server.version checks
|
||||||
|
// succeed under the default production posture (auth.allow_anonymous:
|
||||||
|
// false). resolveBearerToken returns null when no env var is set; the
|
||||||
|
// doctor still runs but distinguishes 401 from "server down" by status
|
||||||
|
// code per the updated check.
|
||||||
const result = await runDoctor({
|
const result = await runDoctor({
|
||||||
olpHome,
|
olpHome,
|
||||||
proxyUrl,
|
proxyUrl,
|
||||||
checkFilter,
|
checkFilter,
|
||||||
|
authHeaders: authHeaders(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (io.wantJson) {
|
if (io.wantJson) {
|
||||||
|
|||||||
@@ -214,6 +214,24 @@ alongside the "using server-advertised key" notice.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Deployment configurations (D76 amendment, 2026-05-26)
|
||||||
|
|
||||||
|
Original ADR 0011 referenced a `BIND_ADDRESS` concept that did not exist in the v0.4.0–v0.4.2 codebase — the server was hard-coded to `server.listen(PORT, '127.0.0.1', ...)`. D76 closes this gap by adding the `OLP_BIND` env var (default `127.0.0.1`), making the deployment-context discussion below operational rather than aspirational.
|
||||||
|
|
||||||
|
Three deployment configurations are supported:
|
||||||
|
|
||||||
|
| `OLP_BIND` value | Reachability | Anonymous-key publication |
|
||||||
|
|---|---|---|
|
||||||
|
| `127.0.0.1` (default) | Loopback only | Safe with any auth posture (no LAN exposure at all) |
|
||||||
|
| RFC1918 IP / tailnet IP / `0.0.0.0` on a trusted LAN | LAN clients only | Safe when `advertise_anonymous_key: true` — the documented "trusted-LAN" zero-config family onboarding flow |
|
||||||
|
| Public IP / `0.0.0.0` on a public-facing host | Public internet | **Incompatible with `advertise_anonymous_key: true`.** Operator MUST keep `advertise_anonymous_key: false` (default). |
|
||||||
|
|
||||||
|
The server emits a startup warn event `anonymous_key_advertised_with_lan_bind` when `OLP_BIND` is non-loopback AND `advertise_anonymous_key: true` (per the `lib/keys.mjs` + `server.mjs` checks). The warn is a **checkpoint, not a hard gate** — the server cannot tell from the bind address alone whether the operator is on a trusted LAN (RFC1918 / tailnet) or has accidentally exposed a public IP. The Re-evaluation trigger #1 below escalates to a hard gate when OLP gains a public-internet deployment mode.
|
||||||
|
|
||||||
|
`olp-connect <ip>` consumes `/health.anonymousKey` over the network — therefore requires `OLP_BIND` to include the LAN interface on the server side. Without setting `OLP_BIND=<lan-ip>` (or `0.0.0.0`), `olp-connect <ip>` will fail with `connect ECONNREFUSED` because the server only accepts loopback connections.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Re-evaluation triggers
|
## Re-evaluation triggers
|
||||||
|
|
||||||
Re-open this ADR when ANY of the following fires:
|
Re-open this ADR when ANY of the following fires:
|
||||||
|
|||||||
+33
-2
@@ -144,6 +144,15 @@ export function buildBuiltinChecks(opts = {}) {
|
|||||||
const olpHome = resolveOlpHome(opts);
|
const olpHome = resolveOlpHome(opts);
|
||||||
const configPath = join(olpHome, 'config.json');
|
const configPath = join(olpHome, 'config.json');
|
||||||
const proxyUrl = resolveProxyUrl(opts);
|
const proxyUrl = resolveProxyUrl(opts);
|
||||||
|
// D74 P1-1 fix: server.running and server.version probe /health, which
|
||||||
|
// under default production posture (auth.allow_anonymous: false) requires
|
||||||
|
// an Authorization: Bearer header. Without it, the probe gets 401 and
|
||||||
|
// doctor falsely reports the server down. Caller passes the resolved
|
||||||
|
// bearer token via opts.authHeaders (a `{Authorization: 'Bearer ...'}`
|
||||||
|
// object). Empty headers means "no token configured" — the probe still
|
||||||
|
// fires but a 401 response is treated as "auth misconfigured" rather
|
||||||
|
// than "server down" (see server.running check below).
|
||||||
|
const authHeaders = opts.authHeaders ?? {};
|
||||||
|
|
||||||
const checks = [];
|
const checks = [];
|
||||||
|
|
||||||
@@ -298,7 +307,9 @@ export function buildBuiltinChecks(opts = {}) {
|
|||||||
id: 'server.running',
|
id: 'server.running',
|
||||||
category: 'server',
|
category: 'server',
|
||||||
async run() {
|
async run() {
|
||||||
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 });
|
// D74 P1-1: pass authHeaders so the probe works under the default
|
||||||
|
// production posture (auth.allow_anonymous: false).
|
||||||
|
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return {
|
return {
|
||||||
status: 'fail',
|
status: 'fail',
|
||||||
@@ -311,6 +322,25 @@ export function buildBuiltinChecks(opts = {}) {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
// 401: server is up but the caller has no/wrong bearer token. NOT a
|
||||||
|
// "server down" condition — distinguish so the kind discriminator
|
||||||
|
// doesn't route to fix_server when the user just needs OLP_API_KEY.
|
||||||
|
if (r.status === 401 || r.status === 403) {
|
||||||
|
return {
|
||||||
|
status: 'fail',
|
||||||
|
message: `${proxyUrl}/health returned ${r.status} — server is up but the bearer token is missing or invalid. Set OLP_API_KEY env to an owner-tier token (npx olp-keys list).`,
|
||||||
|
evidence: {
|
||||||
|
fix_commands: [
|
||||||
|
'echo "set OLP_API_KEY=<your owner token> or OLP_OWNER_TOKEN=<...> then rerun olp doctor"',
|
||||||
|
],
|
||||||
|
human_required: [
|
||||||
|
'Locate an owner-tier OLP API key plaintext (or run `npx olp-keys keygen --owner` to mint a new one — printed ONCE).',
|
||||||
|
'Export it: `export OLP_API_KEY=olp_...`',
|
||||||
|
],
|
||||||
|
reference: 'docs/adr/0007-multi-key-auth.md § 9.1 + README § Environment Variables',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
if (r.status !== 200) {
|
if (r.status !== 200) {
|
||||||
return { status: 'fail', message: `${proxyUrl}/health returned status=${r.status}` };
|
return { status: 'fail', message: `${proxyUrl}/health returned status=${r.status}` };
|
||||||
}
|
}
|
||||||
@@ -333,7 +363,8 @@ export function buildBuiltinChecks(opts = {}) {
|
|||||||
} catch {
|
} catch {
|
||||||
return { status: 'warn', message: 'Could not read local package.json — skipping version comparison' };
|
return { status: 'warn', message: 'Could not read local package.json — skipping version comparison' };
|
||||||
}
|
}
|
||||||
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000 });
|
// D74 P1-1: same auth-headers fix as server.running.
|
||||||
|
const r = await httpGet(`${proxyUrl}/health`, { timeoutMs: 3000, headers: authHeaders });
|
||||||
if (!r.ok || r.status !== 200) {
|
if (!r.ok || r.status !== 200) {
|
||||||
return { status: 'warn', message: `Could not fetch /health to compare version (${r.error ?? `status ${r.status}`})` };
|
return { status: 'warn', message: `Could not fetch /health to compare version (${r.error ?? `status ${r.status}`})` };
|
||||||
}
|
}
|
||||||
|
|||||||
+108
-3
@@ -157,6 +157,36 @@ function resolveCodexBin() {
|
|||||||
// D6 assumption A2: auth file is named auth.json (unconfirmed — D7 will pin).
|
// D6 assumption A2: auth file is named auth.json (unconfirmed — D7 will pin).
|
||||||
// D6 assumption A3: access token field is `access_token` or `token` (unconfirmed).
|
// D6 assumption A3: access token field is `access_token` or `token` (unconfirmed).
|
||||||
//
|
//
|
||||||
|
// ── D75 (v0.4.2) F1 — codex CLI v0.133.0 schema pin ─────────────────────────
|
||||||
|
//
|
||||||
|
// Real codex CLI v0.133.0 auth.json (verified empirically on PI231 / Mac mini,
|
||||||
|
// 2026-05-26 E2E session):
|
||||||
|
//
|
||||||
|
// {
|
||||||
|
// "auth_mode": "chatgpt",
|
||||||
|
// "OPENAI_API_KEY": null | "<key>",
|
||||||
|
// "tokens": {
|
||||||
|
// "id_token": "<JWT>",
|
||||||
|
// "access_token": "<opaque-or-JWT>", <-- THIS is the access token
|
||||||
|
// "refresh_token": "<opaque>",
|
||||||
|
// "account_id": "<uuid>"
|
||||||
|
// },
|
||||||
|
// "last_refresh": "<ISO8601>"
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// D6 assumption A3 originally tried `creds.access_token` at TOP level. Under
|
||||||
|
// codex v0.133.0 this field does not exist at the top level → readAuthArtifact()
|
||||||
|
// returned null even when the user had fully completed `codex login`. OLP then
|
||||||
|
// reported "auth artifact missing" via /health and `olp doctor`, and refused
|
||||||
|
// to spawn codex — false negative blocking the entire openai provider.
|
||||||
|
//
|
||||||
|
// Fix: prepend `creds?.tokens?.access_token` to the precedence chain. Keep all
|
||||||
|
// existing fallbacks unchanged so older codex CLI versions (pre-v0.133) and any
|
||||||
|
// future shape variants still resolve.
|
||||||
|
//
|
||||||
|
// Authority pin: codex CLI v0.133.0 source + on-disk auth.json captured during
|
||||||
|
// PI231 E2E. See D75 commit body for the verification transcript.
|
||||||
|
//
|
||||||
// Returns { accessToken: string } or null (never throws).
|
// Returns { accessToken: string } or null (never throws).
|
||||||
export function readAuthArtifact() {
|
export function readAuthArtifact() {
|
||||||
// 1. Explicit test override — always takes precedence.
|
// 1. Explicit test override — always takes precedence.
|
||||||
@@ -165,7 +195,12 @@ export function readAuthArtifact() {
|
|||||||
try {
|
try {
|
||||||
const raw = readFileSync(authPathOverride, 'utf8');
|
const raw = readFileSync(authPathOverride, 'utf8');
|
||||||
const creds = JSON.parse(raw);
|
const creds = JSON.parse(raw);
|
||||||
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
|
// D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
|
||||||
|
// Preserve top-level fallbacks for backward / forward compat.
|
||||||
|
const token = creds?.tokens?.access_token
|
||||||
|
?? creds?.access_token
|
||||||
|
?? creds?.token
|
||||||
|
?? creds?.accessToken;
|
||||||
if (token && typeof token === 'string') return { accessToken: token };
|
if (token && typeof token === 'string') return { accessToken: token };
|
||||||
} catch { /* fall through */ }
|
} catch { /* fall through */ }
|
||||||
return null; // explicit path set but file missing / malformed
|
return null; // explicit path set but file missing / malformed
|
||||||
@@ -178,8 +213,12 @@ export function readAuthArtifact() {
|
|||||||
try {
|
try {
|
||||||
const raw = readFileSync(authPath, 'utf8');
|
const raw = readFileSync(authPath, 'utf8');
|
||||||
const creds = JSON.parse(raw);
|
const creds = JSON.parse(raw);
|
||||||
// D6 assumption A3: try common OAuth field names in precedence order.
|
// D75 F1: codex CLI v0.133.0 nests the token under `tokens.access_token`.
|
||||||
const token = creds?.access_token ?? creds?.token ?? creds?.accessToken;
|
// Try the nested location FIRST, then fall back to legacy top-level fields.
|
||||||
|
const token = creds?.tokens?.access_token
|
||||||
|
?? creds?.access_token
|
||||||
|
?? creds?.token
|
||||||
|
?? creds?.accessToken;
|
||||||
if (token && typeof token === 'string') return { accessToken: token };
|
if (token && typeof token === 'string') return { accessToken: token };
|
||||||
} catch { /* file missing or malformed */ }
|
} catch { /* file missing or malformed */ }
|
||||||
|
|
||||||
@@ -242,9 +281,30 @@ export function irToCodex(irRequest) {
|
|||||||
// model string (e.g., gpt-5.5, gpt-5.4, gpt-5.3-codex).
|
// model string (e.g., gpt-5.5, gpt-5.4, gpt-5.3-codex).
|
||||||
// PROMPT: "Initial instruction for the task. Use '-' to pipe the prompt
|
// PROMPT: "Initial instruction for the task. Use '-' to pipe the prompt
|
||||||
// from stdin."
|
// from stdin."
|
||||||
|
//
|
||||||
|
// ── D75 (v0.4.2) F2 — codex CLI v0.133.0 trusted-directory sandbox ─────────
|
||||||
|
// codex CLI v0.133.0 added a trusted-directory sandbox: invocations outside
|
||||||
|
// a git repo (or outside any directory explicitly trusted via
|
||||||
|
// `codex config trusted-directories`) refuse with:
|
||||||
|
// "Not inside a trusted directory and --skip-git-repo-check was not specified."
|
||||||
|
// and exit non-zero with zero NDJSON output → OLP surfaces SPAWN_FAILED with
|
||||||
|
// no usable chunks → fallback engine advances to next hop unnecessarily.
|
||||||
|
//
|
||||||
|
// The CWD that OLP spawns from is typically the server install dir (`~/olp/`
|
||||||
|
// on Pi231) which is a git repo on maintainer workstations but is NOT a git
|
||||||
|
// repo on most operator hosts. We bypass the sandbox unconditionally because
|
||||||
|
// OLP is the trusted caller (it is the operator's own server invoking its own
|
||||||
|
// configured Codex subscription via the documented `codex exec` automation
|
||||||
|
// entry point). The trusted-directory sandbox is a foot-gun safeguard for
|
||||||
|
// interactive users; OLP's spawn is non-interactive and pre-authorized.
|
||||||
|
//
|
||||||
|
// Authority: codex CLI v0.133.0 release notes / `codex exec --help` output
|
||||||
|
// documenting `--skip-git-repo-check`. Verified empirically on PI231 E2E
|
||||||
|
// 2026-05-26.
|
||||||
const args = [
|
const args = [
|
||||||
'exec',
|
'exec',
|
||||||
'--json',
|
'--json',
|
||||||
|
'--skip-git-repo-check',
|
||||||
'--model', irRequest.model,
|
'--model', irRequest.model,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -291,6 +351,51 @@ export function codexChunkToIR(rawNDJSONLine) {
|
|||||||
|
|
||||||
if (!event || typeof event !== 'object') return null;
|
if (!event || typeof event !== 'object') return null;
|
||||||
|
|
||||||
|
// ── D75 (v0.4.2) F3 — codex CLI v0.133.0 event shape pin ─────────────────
|
||||||
|
// Real codex CLI v0.133.0 NDJSON event stream (verified empirically on PI231
|
||||||
|
// / Mac mini, 2026-05-26 E2E session):
|
||||||
|
// {"type":"thread.started","thread_id":"019e..."}
|
||||||
|
// {"type":"turn.started"}
|
||||||
|
// {"type":"item.started","item":{"id":"item_0","type":"reasoning","text":""}}
|
||||||
|
// {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"<response>"}}
|
||||||
|
// {"type":"turn.completed","usage":{"input_tokens":..,"output_tokens":..}}
|
||||||
|
//
|
||||||
|
// The D6 defensive parser recognized `content`/`delta`/`text` fields at the
|
||||||
|
// top level and `type === 'stop'`/`done === true`. None of these match
|
||||||
|
// v0.133.0's actual shape → every chunk was silently dropped → response body
|
||||||
|
// had `content: null`. F3 adds three NEW recognizers (item.completed →
|
||||||
|
// agent_message; turn.completed → stop; turn.failed → error) BEFORE the
|
||||||
|
// legacy fallback chain. Legacy recognizers preserved for forward/backward
|
||||||
|
// compat (older codex versions; future shape variants).
|
||||||
|
|
||||||
|
// F3-a: agent_message item completion.
|
||||||
|
// codex v0.133.0 emits assistant text as a single item.completed event whose
|
||||||
|
// item.type is 'agent_message' and item.text carries the full text. There
|
||||||
|
// are no incremental deltas — the entire response arrives in one chunk.
|
||||||
|
if (event.type === 'item.completed'
|
||||||
|
&& event.item?.type === 'agent_message'
|
||||||
|
&& typeof event.item?.text === 'string') {
|
||||||
|
return { type: 'delta', content: event.item.text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// F3-b: turn completion → stop chunk.
|
||||||
|
// codex v0.133.0 emits turn.completed with a usage block when the model
|
||||||
|
// finishes. We map this to IR stop with finish_reason 'stop'.
|
||||||
|
if (event.type === 'turn.completed') {
|
||||||
|
return { type: 'stop', finish_reason: 'stop' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// F3-c: turn failure → error chunk.
|
||||||
|
// codex v0.133.0 emits turn.failed with an embedded error object when the
|
||||||
|
// turn cannot complete. Extract a human-readable message for the IR error.
|
||||||
|
if (event.type === 'turn.failed') {
|
||||||
|
const errMsg = (typeof event.error === 'string')
|
||||||
|
? event.error
|
||||||
|
: (event.error?.message ?? 'codex turn.failed');
|
||||||
|
return { type: 'error', error: errMsg };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Legacy/fallback recognizers (kept for backward + forward compat) ────
|
||||||
// Error event: type === 'error' or error field present
|
// Error event: type === 'error' or error field present
|
||||||
// A4: defensive — error shape unconfirmed; D7 will pin actual field names
|
// A4: defensive — error shape unconfirmed; D7 will pin actual field names
|
||||||
if (event.type === 'error' || (event.error && typeof event.error === 'string')) {
|
if (event.type === 'error' || (event.error && typeof event.error === 'string')) {
|
||||||
|
|||||||
+23
-3
@@ -138,12 +138,32 @@ export function fmtHealth(body) {
|
|||||||
if (body.uptime_human || body.uptimeHuman) {
|
if (body.uptime_human || body.uptimeHuman) {
|
||||||
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
|
out += `Uptime: ${body.uptime_human ?? body.uptimeHuman}\n`;
|
||||||
}
|
}
|
||||||
|
// D74 P2-4 fix: server.mjs /health full payload is
|
||||||
|
// body.providers = { enabled: N, available: N, status: { <name>: {...} } }
|
||||||
|
// The plugin previously iterated Object.entries(body.providers), which
|
||||||
|
// surfaced `enabled`, `available`, and `status` as pseudo-providers
|
||||||
|
// (typeof status === 'object' → loop body fired with name='status').
|
||||||
|
// Walk providers.status when present; fall back to providers.* for the
|
||||||
|
// older OCP shape that lacks the .status wrapper.
|
||||||
if (body.providers && typeof body.providers === "object") {
|
if (body.providers && typeof body.providers === "object") {
|
||||||
|
const enabled = body.providers.enabled;
|
||||||
|
const available = body.providers.available;
|
||||||
|
if (typeof enabled === "number" || typeof available === "number") {
|
||||||
|
out += `Providers: ${enabled ?? "?"} enabled / ${available ?? "?"} available\n`;
|
||||||
|
}
|
||||||
|
const statusMap = body.providers.status && typeof body.providers.status === "object"
|
||||||
|
? body.providers.status
|
||||||
|
: body.providers;
|
||||||
|
const entries = Object.entries(statusMap).filter(
|
||||||
|
([name, s]) => typeof s === "object" && s !== null && name !== "enabled" && name !== "available" && name !== "status"
|
||||||
|
);
|
||||||
|
if (entries.length > 0) {
|
||||||
out += `\nProviders:\n`;
|
out += `\nProviders:\n`;
|
||||||
for (const [name, s] of Object.entries(body.providers)) {
|
for (const [name, s] of entries) {
|
||||||
if (typeof s !== "object" || s === null) continue;
|
|
||||||
const i = statusIcon(s?.ok ? "ok" : "fail");
|
const i = statusIcon(s?.ok ? "ok" : "fail");
|
||||||
out += ` ${i} ${name}\n`;
|
const spawn = typeof s?.activeSpawns === "number" ? ` spawns=${s.activeSpawns}` : "";
|
||||||
|
out += ` ${i} ${name}${spawn}\n`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "olp",
|
"name": "olp",
|
||||||
"version": "0.4.0",
|
"version": "0.4.3",
|
||||||
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
"description": "Personal multi-provider LLM proxy. Successor to OCP. One HTTP endpoint, multiple subscriptions behind it, automatic routing + fallback + caching.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "server.mjs",
|
"main": "server.mjs",
|
||||||
|
|||||||
+59
-8
@@ -18,6 +18,14 @@
|
|||||||
* so OLP can co-host with OCP for migration windows. ADR 0010 §
|
* so OLP can co-host with OCP for migration windows. ADR 0010 §
|
||||||
* Default port. Set OLP_PORT=3456 explicitly to restore the
|
* Default port. Set OLP_PORT=3456 explicitly to restore the
|
||||||
* pre-D60 default when not co-hosting with OCP.)
|
* pre-D60 default when not co-hosting with OCP.)
|
||||||
|
* OLP_BIND — listen address (default: 127.0.0.1 since D76 / v0.4.3). Set
|
||||||
|
* to 0.0.0.0 (or a specific interface IP) to accept connections
|
||||||
|
* from LAN clients (required for olp-connect <ip> to actually
|
||||||
|
* reach the server). Server emits a startup warn if BIND
|
||||||
|
* resolves to a non-loopback address AND auth.allow_anonymous
|
||||||
|
* is true (anonymous-key over LAN may be acceptable; anonymous-
|
||||||
|
* key over public internet is not — see ADR 0011 § Deployment
|
||||||
|
* configurations).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { createServer } from 'node:http';
|
import { createServer } from 'node:http';
|
||||||
@@ -77,6 +85,14 @@ const pkg = JSON.parse(readFileSync(join(__dirname, 'package.json'), 'utf8'));
|
|||||||
const VERSION = pkg.version;
|
const VERSION = pkg.version;
|
||||||
|
|
||||||
const PORT = parseInt(process.env.OLP_PORT ?? '4567', 10);
|
const PORT = parseInt(process.env.OLP_PORT ?? '4567', 10);
|
||||||
|
// F5 / D76: OLP_BIND env. Defaults to 127.0.0.1 (loopback only — secure
|
||||||
|
// default). Operators expose LAN by setting OLP_BIND=0.0.0.0 (or a specific
|
||||||
|
// interface). Per ADR 0011 § Deployment configurations:
|
||||||
|
// - 127.0.0.1: trusted single-machine; safe with any auth posture
|
||||||
|
// - RFC1918 / tailnet / specific LAN IP: trusted-LAN — anonymous_key OK
|
||||||
|
// - 0.0.0.0: ALL interfaces — operator MUST ensure auth posture matches the
|
||||||
|
// network reachability (e.g. no advertise_anonymous_key on public IP)
|
||||||
|
const BIND = process.env.OLP_BIND ?? '127.0.0.1';
|
||||||
const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
|
const BODY_LIMIT = 5 * 1024 * 1024; // 5 MB
|
||||||
|
|
||||||
// ── Logging ───────────────────────────────────────────────────────────────
|
// ── Logging ───────────────────────────────────────────────────────────────
|
||||||
@@ -253,6 +269,19 @@ if (_authConfig.advertise_anonymous_key === true) {
|
|||||||
message: 'auth.advertise_anonymous_key=true but no active key with plaintext_advertise exists. Run `olp-keys keygen --anonymous --advertise` to create one. /health.anonymousKey will NOT be emitted until then. See ADR 0011.',
|
message: 'auth.advertise_anonymous_key=true but no active key with plaintext_advertise exists. Run `olp-keys keygen --anonymous --advertise` to create one. /health.anonymousKey will NOT be emitted until then. See ADR 0011.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// F5 / D76 (ADR 0011 Deployment configurations): publishing the anonymous
|
||||||
|
// key via /health is only safe when /health is reachable ONLY from a
|
||||||
|
// trusted network. If OLP_BIND is set to a non-loopback address AND
|
||||||
|
// advertise_anonymous_key is true, warn the operator. We can't tell from
|
||||||
|
// here whether the non-loopback bind is "trusted LAN" (RFC1918 / tailnet)
|
||||||
|
// or "public internet" — that's the operator's responsibility. The warn is
|
||||||
|
// a checkpoint, not a hard gate.
|
||||||
|
if (BIND !== '127.0.0.1' && BIND !== 'localhost' && BIND !== '::1') {
|
||||||
|
logEvent('warn', 'anonymous_key_advertised_with_lan_bind', {
|
||||||
|
message: `auth.advertise_anonymous_key=true with OLP_BIND=${BIND} — /health.anonymousKey will be reachable from any host that can connect to ${BIND}:${PORT}. Confirm this address is on a trusted LAN (RFC1918 / tailnet) — never a public IP. See ADR 0011 § Deployment configurations.`,
|
||||||
|
bind: BIND,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
/** @internal — test seam: inject a synthetic auth config (no file I/O). */
|
||||||
@@ -1224,13 +1253,25 @@ async function handleChatCompletions(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
// try/finally: releaseSpawn MUST fire on every exit path — success
|
// D75 (v0.4.2) F7 — per-hop model override.
|
||||||
// (return at end), spawn throw (caught and re-thrown below), or the
|
// The chain config (routing.chains[<requested-model>][<hop>].model) is the
|
||||||
// D16 truncation-salvage return. The finally is the only mechanism
|
// model name to pass to THIS hop's provider plugin — NOT the model the
|
||||||
// that guarantees release across all three.
|
// user originally requested. Pre-D75, executeHopFn used hopModel for the
|
||||||
|
// cache key + audit ctx but passed the ORIGINAL irReq (with irReq.model
|
||||||
|
// still set to the user's request) into hopProviderPlugin.spawn(). Result:
|
||||||
|
// a 2-hop chain like [{anthropic, claude-sonnet-4-6}, {openai, gpt-5.5}]
|
||||||
|
// would spawn codex with --model claude-sonnet-4-6 on hop 1 — openai rejects
|
||||||
|
// the unknown model and the chain dies. This broke the core OLP value prop
|
||||||
|
// (cross-provider fallback with provider-appropriate model substitution).
|
||||||
|
//
|
||||||
|
// Fix: build a per-hop IR variant with the hop's model substituted. Skip
|
||||||
|
// the clone when hopModel === irReq.model (single-provider chains and
|
||||||
|
// chain hops whose model matches the request). Authority: ADR 0004 §
|
||||||
|
// Chain advancement step 1 (per-hop config supplies provider AND model).
|
||||||
|
const hopIrReq = irReq.model === hopModel ? irReq : { ...irReq, model: hopModel };
|
||||||
try {
|
try {
|
||||||
try {
|
try {
|
||||||
for await (const irChunk of hopProviderPlugin.spawn(irReq, authContext)) {
|
for await (const irChunk of hopProviderPlugin.spawn(hopIrReq, authContext)) {
|
||||||
// D16: check error chunks BEFORE pushing — preserves the invariant that
|
// D16: check error chunks BEFORE pushing — preserves the invariant that
|
||||||
// chunks array contains only delta/stop chunks. Without this, the catch
|
// chunks array contains only delta/stop chunks. Without this, the catch
|
||||||
// block's `chunks.length > 0` would mistake a single error chunk for
|
// block's `chunks.length > 0` would mistake a single error chunk for
|
||||||
@@ -1422,9 +1463,16 @@ async function handleChatCompletions(req, res) {
|
|||||||
// releaseSpawn fires exactly once in finally — regardless of normal
|
// releaseSpawn fires exactly once in finally — regardless of normal
|
||||||
// exhaustion, mid-stream throw, or iterator.return() from cache-layer
|
// exhaustion, mid-stream throw, or iterator.return() from cache-layer
|
||||||
// sourceAbortController propagation (§9).
|
// sourceAbortController propagation (§9).
|
||||||
|
//
|
||||||
|
// D75 (v0.4.2) F7 — per-hop model override (streaming path).
|
||||||
|
// Mirror the buffered-path fix: pass the hop's configured model (streamModel)
|
||||||
|
// into streamPlugin.spawn(), not the user's original ir.model. Skip the
|
||||||
|
// clone when ir.model === streamModel. See executeHopFn() above for the
|
||||||
|
// full F7 rationale + authority citation.
|
||||||
|
const streamIr = ir.model === streamModel ? ir : { ...ir, model: streamModel };
|
||||||
return (async function* sourceWithRelease() {
|
return (async function* sourceWithRelease() {
|
||||||
try {
|
try {
|
||||||
for await (const irChunk of streamPlugin.spawn(ir, authContext)) {
|
for await (const irChunk of streamPlugin.spawn(streamIr, authContext)) {
|
||||||
yield irChunk;
|
yield irChunk;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -2239,10 +2287,13 @@ const isMain = (() => {
|
|||||||
|
|
||||||
if (isMain) {
|
if (isMain) {
|
||||||
const server = createOlpServer();
|
const server = createOlpServer();
|
||||||
server.listen(PORT, '127.0.0.1', () => {
|
server.listen(PORT, BIND, () => {
|
||||||
const enabledCount = loadedProviders.size;
|
const enabledCount = loadedProviders.size;
|
||||||
|
// D74 P3-5: banner no longer hardcodes the phase. Derives from VERSION
|
||||||
|
// (which advances at every Phase close) so banner stays accurate
|
||||||
|
// without future-maintenance touch-ups at every Phase boundary.
|
||||||
process.stdout.write(
|
process.stdout.write(
|
||||||
`OLP v${VERSION} listening on :${PORT} (${enabledCount} providers enabled — Phase 1 in progress)\n`,
|
`OLP v${VERSION} listening on :${PORT} (${enabledCount} providers enabled)\n`,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14791,3 +14791,611 @@ describe('Suite 35 — D71 olp-plugin/ smoke tests (ADR 0010 § Phase 4 D71-D73)
|
|||||||
assert.match(r.text, /not owner-tier/);
|
assert.match(r.text, /not owner-tier/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Suite 36: D74 v0.4.1 hotfix regression tests (maintainer-review findings) ─
|
||||||
|
//
|
||||||
|
// These tests pin the FIVE issues maintainer caught in independent post-v0.4.0
|
||||||
|
// review that previous D-day reviewers all missed (because they reviewed against
|
||||||
|
// spec text, not runtime contracts). Each test exercises a real codepath that,
|
||||||
|
// pre-D74, would have produced the documented wrong behavior.
|
||||||
|
|
||||||
|
import { writeFileSync as _writeFileSyncS36, readFileSync as _readFileSyncS36, mkdtempSync as _mkdtempSyncS36, rmSync as _rmSyncS36 } from 'node:fs';
|
||||||
|
import { tmpdir as _tmpdirS36 } from 'node:os';
|
||||||
|
import { join as _joinS36 } from 'node:path';
|
||||||
|
import { spawnSync as _spawnSyncS36 } from 'node:child_process';
|
||||||
|
|
||||||
|
describe('Suite 36 — D74 v0.4.1 hotfix regression (maintainer review findings)', () => {
|
||||||
|
it('36a (P1-1) — runDoctor accepts authHeaders + passes them to server.running probe', async () => {
|
||||||
|
// Pre-D74: lib/doctor.mjs called httpGet(`${proxyUrl}/health`) with no
|
||||||
|
// headers, so under default `auth.allow_anonymous: false` the probe got
|
||||||
|
// 401 and reported "server down" — false negative. D74 threads
|
||||||
|
// authHeaders through buildBuiltinChecks. This test confirms the wire.
|
||||||
|
const { buildBuiltinChecks } = await import('./lib/doctor.mjs');
|
||||||
|
const tmpHome = _mkdtempSyncS36(_joinS36(_tmpdirS36(), 'olp-d74a-'));
|
||||||
|
const checks = buildBuiltinChecks({
|
||||||
|
olpHome: tmpHome,
|
||||||
|
proxyUrl: 'http://127.0.0.1:1', // closed port — probe fails with ECONN
|
||||||
|
authHeaders: { Authorization: 'Bearer olp_FAKE' },
|
||||||
|
});
|
||||||
|
const serverRunning = checks.find(c => c.id === 'server.running');
|
||||||
|
assert.ok(serverRunning, 'server.running check must exist');
|
||||||
|
// We can't easily intercept httpGet here without DI, but the check at
|
||||||
|
// least accepts the opts and runs. Real wire-level coverage is 36b below.
|
||||||
|
_rmSyncS36(tmpHome, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36b (P1-1) — server.running distinguishes 401 (server up, bad token) from "server down"', async () => {
|
||||||
|
__setAuthConfig({ allow_anonymous: false, owner_only_endpoints: ['/health'] });
|
||||||
|
const serverMod = await import('./server.mjs');
|
||||||
|
const s = serverMod.createOlpServer();
|
||||||
|
let port36b = 28300 + Math.floor(Math.random() * 100);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
s.listen(port36b, '127.0.0.1', resolve);
|
||||||
|
s.once('error', e => {
|
||||||
|
if (e.code === 'EADDRINUSE') { port36b++; s.listen(port36b, '127.0.0.1', resolve); s.once('error', reject); }
|
||||||
|
else reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const { runDoctor } = await import('./lib/doctor.mjs');
|
||||||
|
try {
|
||||||
|
const result = await runDoctor({
|
||||||
|
olpHome: process.env.OLP_HOME,
|
||||||
|
proxyUrl: `http://127.0.0.1:${port36b}`,
|
||||||
|
// intentionally NO authHeaders — simulates user without OLP_API_KEY
|
||||||
|
});
|
||||||
|
const sr = result.checks.find(c => c.id === 'server.running');
|
||||||
|
assert.ok(sr, 'server.running present');
|
||||||
|
assert.equal(sr.status, 'fail', 'fails because no token');
|
||||||
|
assert.match(sr.message, /401|bearer token/i,
|
||||||
|
`must mention 401 / bearer token specifically — got: ${sr.message}`);
|
||||||
|
assert.ok(!/unreachable/i.test(sr.message),
|
||||||
|
'"unreachable" would falsely imply server is down (P1-1 bug)');
|
||||||
|
} finally {
|
||||||
|
await new Promise(r => s.close(r));
|
||||||
|
__resetAuthConfig();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36c (P1-2) — olp-connect rejects malformed --key', () => {
|
||||||
|
// The bash validator: ^olp_[A-Za-z0-9_-]{43}$. Anything else is rejected
|
||||||
|
// before it can touch a shell rc file or environment.d entry.
|
||||||
|
const result = _spawnSyncS36('bash', [
|
||||||
|
_joinS36(import.meta.dirname ?? process.cwd(), 'bin/olp-connect'),
|
||||||
|
'127.0.0.1',
|
||||||
|
'--port', '1',
|
||||||
|
'--key', 'not-an-olp-token',
|
||||||
|
'--dry-run',
|
||||||
|
], { encoding: 'utf8', timeout: 5000 });
|
||||||
|
assert.equal(result.status, 1, `expected exit 1 for malformed --key, got ${result.status}; stderr=${result.stderr.slice(0, 300)}`);
|
||||||
|
assert.match(result.stderr + result.stdout, /token format does not match/i,
|
||||||
|
'must surface the validator error');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36d (P1-2) — olp-connect accepts a properly-formed olp_ token', () => {
|
||||||
|
// Pad to 43 base64url chars after olp_. Don't actually hit the server —
|
||||||
|
// use --port 1 (closed) which makes the connectivity probe fail at
|
||||||
|
// the connect step (exit 2), but only AFTER the --key validator passes.
|
||||||
|
const goodKey = 'olp_' + 'A'.repeat(43);
|
||||||
|
const result = _spawnSyncS36('bash', [
|
||||||
|
_joinS36(import.meta.dirname ?? process.cwd(), 'bin/olp-connect'),
|
||||||
|
'127.0.0.1',
|
||||||
|
'--port', '1',
|
||||||
|
'--key', goodKey,
|
||||||
|
'--dry-run',
|
||||||
|
], { encoding: 'utf8', timeout: 5000 });
|
||||||
|
// Validator passes → reaches connectivity step → exits 2 (or whatever
|
||||||
|
// bash propagation gives) but NOT exit 1 from the validator.
|
||||||
|
assert.notEqual(result.status, 1, `expected validator to pass; got exit 1 with stderr=${result.stderr.slice(0, 300)}`);
|
||||||
|
assert.ok(!/token format does not match/i.test(result.stderr + result.stdout),
|
||||||
|
'validator must NOT trigger for a well-formed token');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36e (P2-3) — CacheStore.stats() returns {hits, misses, size, inflightCount} shape', async () => {
|
||||||
|
// Real cacheStore.stats() returns { hits, misses, size, inflightCount } per
|
||||||
|
// lib/cache/store.mjs. Pre-D74 cmdCache read body.entries / body.bytes /
|
||||||
|
// body.maxBytes — all undefined → output showed "entries: ?" and "bytes: 0".
|
||||||
|
// Pin the shape so a future server-side rename can't reintroduce the bug.
|
||||||
|
// Use the CacheStore class directly (no HTTP) since the field names ARE the
|
||||||
|
// contract — server.mjs handleCacheStats just sendJSON(s, 200, cacheStore.stats()).
|
||||||
|
const { CacheStore: CS36 } = await import('./lib/cache/store.mjs');
|
||||||
|
const cs = new CS36();
|
||||||
|
const stats = cs.stats();
|
||||||
|
assert.ok('size' in stats, 'CacheStore.stats() shape: size field present');
|
||||||
|
assert.ok('hits' in stats, 'CacheStore.stats() shape: hits field present');
|
||||||
|
assert.ok('misses' in stats, 'CacheStore.stats() shape: misses field present');
|
||||||
|
assert.ok('inflightCount' in stats, 'CacheStore.stats() shape: inflightCount field present');
|
||||||
|
assert.ok(!('entries' in stats), 'must NOT have OCP-era entries field');
|
||||||
|
assert.ok(!('bytes' in stats), 'must NOT have OCP-era bytes field');
|
||||||
|
assert.ok(!('maxBytes' in stats), 'must NOT have OCP-era maxBytes field');
|
||||||
|
// Also pin that cmdCache reads body.size (not body.entries) by grepping the source
|
||||||
|
const cliSrc = _readFileSyncS36(_joinS36(import.meta.dirname ?? process.cwd(), 'bin/olp.mjs'), 'utf8');
|
||||||
|
assert.ok(/body\.size\b/.test(cliSrc), 'cmdCache must read body.size for entries display');
|
||||||
|
assert.ok(/body\.inflightCount\b/.test(cliSrc), 'cmdCache must read body.inflightCount');
|
||||||
|
assert.ok(!/body\.entries\b/.test(cliSrc), 'cmdCache must NOT read body.entries (OCP-era field)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36f (P2-3) — dashboard-data payload + cmdUsage source pin the wire-contract field names', () => {
|
||||||
|
// server.mjs handleManagementDashboardData (~line 2027) builds the payload
|
||||||
|
// inline. Pin by grepping the server source: the keys MUST be window_24h /
|
||||||
|
// cache_hit_24h / quota / spend_trend_30d / top_fallback_chains_24h /
|
||||||
|
// cache_stats. Pre-D74 cmdUsage read body.usage_24h.requests / body.providers
|
||||||
|
// / body.top_fallback_chains — none of which exist in the actual payload.
|
||||||
|
const serverSrc = _readFileSyncS36(_joinS36(import.meta.dirname ?? process.cwd(), 'server.mjs'), 'utf8');
|
||||||
|
assert.ok(/window_24h:\s*auditAggregateRequests/.test(serverSrc),
|
||||||
|
'handleManagementDashboardData must emit window_24h field');
|
||||||
|
assert.ok(/cache_hit_24h:\s*auditCacheHitRateWindow/.test(serverSrc),
|
||||||
|
'handleManagementDashboardData must emit cache_hit_24h field');
|
||||||
|
assert.ok(/top_fallback_chains_24h:/.test(serverSrc),
|
||||||
|
'handleManagementDashboardData must emit top_fallback_chains_24h field (NOT top_fallback_chains)');
|
||||||
|
assert.ok(/cache_stats:\s*cacheStore\.stats/.test(serverSrc),
|
||||||
|
'handleManagementDashboardData must emit cache_stats field');
|
||||||
|
// Now pin cmdUsage reads the matching keys
|
||||||
|
const cliSrc = _readFileSyncS36(_joinS36(import.meta.dirname ?? process.cwd(), 'bin/olp.mjs'), 'utf8');
|
||||||
|
assert.ok(/body\.window_24h\b/.test(cliSrc), 'cmdUsage must read body.window_24h');
|
||||||
|
assert.ok(/body\.cache_hit_24h\b/.test(cliSrc), 'cmdUsage must read body.cache_hit_24h');
|
||||||
|
assert.ok(/body\.top_fallback_chains_24h\b/.test(cliSrc),
|
||||||
|
'cmdUsage must read body.top_fallback_chains_24h (NOT body.top_fallback_chains)');
|
||||||
|
assert.ok(/body\.quota\b/.test(cliSrc), 'cmdUsage must read body.quota');
|
||||||
|
// Pre-D74 stale reads must be GONE from the CLI source
|
||||||
|
assert.ok(!/body\.usage_24h\.requests\b/.test(cliSrc),
|
||||||
|
'cmdUsage must NOT read body.usage_24h.requests (OCP-era field)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36g (P2-4) — olp-plugin fmtHealth iterates providers.status (not providers.*)', async () => {
|
||||||
|
// Real /health full payload: providers: { enabled, available, status: { anthropic: {...} } }
|
||||||
|
// Pre-D74 fmtHealth iterated Object.entries(body.providers) and the body
|
||||||
|
// contained `status: {...}` so the loop printed a pseudo-provider named "status".
|
||||||
|
const { fmtHealth } = await import('./olp-plugin/index.js');
|
||||||
|
const realServerShape = {
|
||||||
|
ok: true,
|
||||||
|
version: '0.4.1',
|
||||||
|
uptime_human: '1m 3s',
|
||||||
|
providers: {
|
||||||
|
enabled: 2,
|
||||||
|
available: 3,
|
||||||
|
status: {
|
||||||
|
anthropic: { ok: true, activeSpawns: 0 },
|
||||||
|
openai: { ok: false, error: 'oauth-missing', activeSpawns: 0 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const out = fmtHealth(realServerShape);
|
||||||
|
assert.match(out, /anthropic/, 'must list anthropic by name');
|
||||||
|
assert.match(out, /openai/, 'must list openai by name');
|
||||||
|
// assert.notMatch isn't available on assert/strict in some Node versions;
|
||||||
|
// use the explicit negation pattern to stay portable.
|
||||||
|
assert.ok(!/^\s*[🟢🔴⚪]\s*status\s*$/m.test(out),
|
||||||
|
'must NOT list "status" as a pseudo-provider (the P2-4 bug)');
|
||||||
|
assert.ok(!/^\s*[🟢🔴⚪]\s*enabled\s*$/m.test(out),
|
||||||
|
'must NOT list "enabled" as a pseudo-provider');
|
||||||
|
assert.ok(!/^\s*[🟢🔴⚪]\s*available\s*$/m.test(out),
|
||||||
|
'must NOT list "available" as a pseudo-provider');
|
||||||
|
// Also confirm the new enabled/available header
|
||||||
|
assert.match(out, /2 enabled \/ 3 available/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36h (P3-5) — server startup banner does not hardcode a stale phase', () => {
|
||||||
|
// Pre-D74 banner literal said "Phase 1 in progress" forever.
|
||||||
|
const serverSrc = _readFileSyncS36(_joinS36(import.meta.dirname ?? process.cwd(), 'server.mjs'), 'utf8');
|
||||||
|
assert.ok(!/Phase 1 in progress/.test(serverSrc),
|
||||||
|
'startup banner must not hardcode "Phase 1 in progress" (stale post v0.4.0)');
|
||||||
|
assert.ok(!/Phase 2 in progress/.test(serverSrc));
|
||||||
|
assert.ok(!/Phase 3 in progress/.test(serverSrc));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── D75 (v0.4.2) extension — real-machine E2E findings F1, F2, F3, F4, F7 ──
|
||||||
|
// Bugs caught on PI231 + Mac mini 2026-05-26 E2E session. Pre-v0.4.0 reviewers
|
||||||
|
// and the post-v0.4.0 maintainer review all missed these because they reviewed
|
||||||
|
// against spec text, not against real provider CLIs running on a remote machine.
|
||||||
|
|
||||||
|
it('36i (F1) — codex readAuthArtifact() recognises v0.133.0 nested tokens.access_token', () => {
|
||||||
|
// Real codex CLI v0.133.0 auth.json shape: { auth_mode, OPENAI_API_KEY,
|
||||||
|
// tokens: { id_token, access_token, refresh_token, account_id }, last_refresh }
|
||||||
|
// Pre-D75 readAuthArtifact read only top-level access_token → returned null →
|
||||||
|
// OLP falsely reported "auth artifact missing" for fully-logged-in users.
|
||||||
|
const tmpDir = _mkdtempSyncS36(_joinS36(_tmpdirS36(), 'olp-d75-f1-nested-'));
|
||||||
|
const authPath = _joinS36(tmpDir, 'auth.json');
|
||||||
|
_writeFileSyncS36(authPath, JSON.stringify({
|
||||||
|
auth_mode: 'chatgpt',
|
||||||
|
OPENAI_API_KEY: null,
|
||||||
|
tokens: {
|
||||||
|
id_token: 'header.body.sig',
|
||||||
|
access_token: 'eyJ-real-nested-access-token-d75-36i',
|
||||||
|
refresh_token: 'refresh-opaque',
|
||||||
|
account_id: '00000000-0000-0000-0000-000000000000',
|
||||||
|
},
|
||||||
|
last_refresh: '2026-05-26T00:00:00Z',
|
||||||
|
}));
|
||||||
|
const savedPath = process.env.OPENAI_CODEX_AUTH_PATH;
|
||||||
|
process.env.OPENAI_CODEX_AUTH_PATH = authPath;
|
||||||
|
try {
|
||||||
|
const result = codexReadAuthArtifact();
|
||||||
|
assert.ok(result, 'must return non-null for v0.133 nested-shape auth.json');
|
||||||
|
assert.equal(result.accessToken, 'eyJ-real-nested-access-token-d75-36i',
|
||||||
|
'must extract token from tokens.access_token (v0.133.0 nested shape)');
|
||||||
|
} finally {
|
||||||
|
if (savedPath !== undefined) process.env.OPENAI_CODEX_AUTH_PATH = savedPath;
|
||||||
|
else delete process.env.OPENAI_CODEX_AUTH_PATH;
|
||||||
|
_rmSyncS36(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36j (F1) — codex readAuthArtifact() still reads legacy top-level access_token', () => {
|
||||||
|
// Backwards compat: older codex CLI versions (pre-v0.133) wrote the token
|
||||||
|
// at top level. D75 fix preserves that fallback so upgrades don't regress.
|
||||||
|
const tmpDir = _mkdtempSyncS36(_joinS36(_tmpdirS36(), 'olp-d75-f1-legacy-'));
|
||||||
|
const authPath = _joinS36(tmpDir, 'auth.json');
|
||||||
|
_writeFileSyncS36(authPath, JSON.stringify({
|
||||||
|
access_token: 'legacy-top-level-token-d75-36j',
|
||||||
|
}));
|
||||||
|
const savedPath = process.env.OPENAI_CODEX_AUTH_PATH;
|
||||||
|
process.env.OPENAI_CODEX_AUTH_PATH = authPath;
|
||||||
|
try {
|
||||||
|
const result = codexReadAuthArtifact();
|
||||||
|
assert.ok(result, 'must return non-null for legacy top-level shape');
|
||||||
|
assert.equal(result.accessToken, 'legacy-top-level-token-d75-36j',
|
||||||
|
'must fall back to top-level access_token when tokens.access_token absent');
|
||||||
|
} finally {
|
||||||
|
if (savedPath !== undefined) process.env.OPENAI_CODEX_AUTH_PATH = savedPath;
|
||||||
|
else delete process.env.OPENAI_CODEX_AUTH_PATH;
|
||||||
|
_rmSyncS36(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36k (F1) — codex readAuthArtifact() returns null for malformed auth.json', () => {
|
||||||
|
// Malformed JSON / missing token / wrong structure → null (not throw).
|
||||||
|
const tmpDir = _mkdtempSyncS36(_joinS36(_tmpdirS36(), 'olp-d75-f1-malformed-'));
|
||||||
|
const savedPath = process.env.OPENAI_CODEX_AUTH_PATH;
|
||||||
|
try {
|
||||||
|
// Case A: unparseable JSON
|
||||||
|
const badJsonPath = _joinS36(tmpDir, 'bad.json');
|
||||||
|
_writeFileSyncS36(badJsonPath, 'not-json-at-all{{{');
|
||||||
|
process.env.OPENAI_CODEX_AUTH_PATH = badJsonPath;
|
||||||
|
assert.equal(codexReadAuthArtifact(), null, 'unparseable JSON → null');
|
||||||
|
|
||||||
|
// Case B: valid JSON but no token field anywhere
|
||||||
|
const noTokenPath = _joinS36(tmpDir, 'no-token.json');
|
||||||
|
_writeFileSyncS36(noTokenPath, JSON.stringify({ auth_mode: 'chatgpt', tokens: {} }));
|
||||||
|
process.env.OPENAI_CODEX_AUTH_PATH = noTokenPath;
|
||||||
|
assert.equal(codexReadAuthArtifact(), null, 'no token in tokens.access_token or top-level → null');
|
||||||
|
|
||||||
|
// Case C: tokens field present but access_token wrong type
|
||||||
|
const wrongTypePath = _joinS36(tmpDir, 'wrong-type.json');
|
||||||
|
_writeFileSyncS36(wrongTypePath, JSON.stringify({
|
||||||
|
tokens: { access_token: 42 },
|
||||||
|
}));
|
||||||
|
process.env.OPENAI_CODEX_AUTH_PATH = wrongTypePath;
|
||||||
|
assert.equal(codexReadAuthArtifact(), null, 'non-string access_token → null');
|
||||||
|
} finally {
|
||||||
|
if (savedPath !== undefined) process.env.OPENAI_CODEX_AUTH_PATH = savedPath;
|
||||||
|
else delete process.env.OPENAI_CODEX_AUTH_PATH;
|
||||||
|
_rmSyncS36(tmpDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36l (F2) — irToCodex() includes --skip-git-repo-check before --model', () => {
|
||||||
|
// codex CLI v0.133.0 sandboxes non-git-repo CWDs by default with:
|
||||||
|
// "Not inside a trusted directory and --skip-git-repo-check was not specified."
|
||||||
|
// OLP servers typically deploy outside a git repo on the operator host. The
|
||||||
|
// --skip-git-repo-check flag must be in args, before --model, so the spawn
|
||||||
|
// succeeds without depending on the install-time CWD.
|
||||||
|
const { args } = irToCodex({
|
||||||
|
irVersion: IR_VERSION,
|
||||||
|
model: 'gpt-5.5',
|
||||||
|
stream: false,
|
||||||
|
messages: [{ role: 'user', content: 'hello' }],
|
||||||
|
});
|
||||||
|
assert.ok(args.includes('--skip-git-repo-check'),
|
||||||
|
'irToCodex().args must include --skip-git-repo-check (F2 fix)');
|
||||||
|
const skipIdx = args.indexOf('--skip-git-repo-check');
|
||||||
|
const modelIdx = args.indexOf('--model');
|
||||||
|
assert.ok(skipIdx > 0 && modelIdx > 0, 'both flags must be present');
|
||||||
|
assert.ok(skipIdx < modelIdx,
|
||||||
|
'--skip-git-repo-check must come before --model (cleaner readability + matches codex v0.133 docs ordering)');
|
||||||
|
// Confirm exec is still first and --json still adjacent
|
||||||
|
assert.equal(args[0], 'exec');
|
||||||
|
assert.equal(args[1], '--json');
|
||||||
|
assert.equal(args[2], '--skip-git-repo-check');
|
||||||
|
assert.equal(args[3], '--model');
|
||||||
|
assert.equal(args[4], 'gpt-5.5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36m (F3) — codexChunkToIR recognises v0.133.0 item.completed agent_message', () => {
|
||||||
|
// Real codex v0.133.0 emits the assistant text as one item.completed event:
|
||||||
|
// {"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"hello world"}}
|
||||||
|
// Pre-D75 codexChunkToIR returned null for this shape → response body had
|
||||||
|
// content: null because the chunk was silently dropped.
|
||||||
|
const result = codexChunkToIR(JSON.stringify({
|
||||||
|
type: 'item.completed',
|
||||||
|
item: { id: 'item_0', type: 'agent_message', text: 'hello world from codex' },
|
||||||
|
}));
|
||||||
|
assert.deepEqual(result, { type: 'delta', content: 'hello world from codex' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36n (F3) — codexChunkToIR recognises v0.133.0 turn.completed → stop', () => {
|
||||||
|
// turn.completed marks the end of a codex turn. Map to IR stop.
|
||||||
|
const result = codexChunkToIR(JSON.stringify({
|
||||||
|
type: 'turn.completed',
|
||||||
|
usage: { input_tokens: 12, output_tokens: 7 },
|
||||||
|
}));
|
||||||
|
assert.deepEqual(result, { type: 'stop', finish_reason: 'stop' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36o (F3) — codexChunkToIR recognises v0.133.0 turn.failed → error', () => {
|
||||||
|
// turn.failed: { type: 'turn.failed', error: { message: 'X' } }
|
||||||
|
const result1 = codexChunkToIR(JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: { message: 'rate limit exceeded' },
|
||||||
|
}));
|
||||||
|
assert.equal(result1?.type, 'error');
|
||||||
|
assert.equal(result1.error, 'rate limit exceeded');
|
||||||
|
// turn.failed with string error
|
||||||
|
const result2 = codexChunkToIR(JSON.stringify({
|
||||||
|
type: 'turn.failed',
|
||||||
|
error: 'simple string error',
|
||||||
|
}));
|
||||||
|
assert.equal(result2?.type, 'error');
|
||||||
|
assert.equal(result2.error, 'simple string error');
|
||||||
|
// turn.failed with no error payload → falls back to default message
|
||||||
|
const result3 = codexChunkToIR(JSON.stringify({ type: 'turn.failed' }));
|
||||||
|
assert.equal(result3?.type, 'error');
|
||||||
|
assert.match(result3.error, /turn\.failed/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36p (F4) — cmdStatus reads body.stats.cache.size (not OCP-era body.cache.entries)', () => {
|
||||||
|
// Server payload (server.mjs handleManagementStatus ~line 2092):
|
||||||
|
// { ok, version, ..., stats: { total_requests, active_requests, cache: { hits, misses, size, inflightCount } } }
|
||||||
|
// Pre-D75 cmdStatus formatter read `body.cache.entries` (OCP-era) → always
|
||||||
|
// undefined → output showed "entries=?". Pin the wire contract by grepping
|
||||||
|
// the CLI source so a future server-side rename can't silently re-break.
|
||||||
|
const cliSrc = _readFileSyncS36(_joinS36(import.meta.dirname ?? process.cwd(), 'bin/olp.mjs'), 'utf8');
|
||||||
|
// Must contain the cmdStatus block that reads body.stats.cache (not body.cache directly)
|
||||||
|
assert.ok(/cmdStatus\b/.test(cliSrc), 'cmdStatus function must exist');
|
||||||
|
// The status block must read body.stats?.cache (the actual server payload nesting)
|
||||||
|
assert.ok(/body\.stats\?\.cache/.test(cliSrc) || /body\.stats\.cache/.test(cliSrc),
|
||||||
|
'cmdStatus must read body.stats.cache for status display');
|
||||||
|
// Specifically, the "entries=" output must derive from c.size (matches CacheStore.stats() shape)
|
||||||
|
// Find the status cache line and verify it uses c.size
|
||||||
|
const statusBlock = cliSrc.match(/cmdStatus[\s\S]+?(?=async function cmd|^\}\s*$)/m);
|
||||||
|
assert.ok(statusBlock, 'cmdStatus block extractable');
|
||||||
|
assert.ok(/c\.size/.test(statusBlock[0]),
|
||||||
|
'cmdStatus cache line must use c.size (matches CacheStore.stats() shape)');
|
||||||
|
// And must NOT use the OCP-era c.entries
|
||||||
|
assert.ok(!/c\.entries/.test(statusBlock[0]),
|
||||||
|
'cmdStatus cache line must NOT read c.entries (OCP-era field that does not exist in CacheStore.stats())');
|
||||||
|
// Also pin the CacheStore.stats() shape contract (re-affirms 36e but for status path)
|
||||||
|
const cs = new CacheStore();
|
||||||
|
const stats = cs.stats();
|
||||||
|
assert.ok('size' in stats, 'CacheStore.stats() exposes size');
|
||||||
|
assert.ok(!('entries' in stats), 'CacheStore.stats() does NOT expose entries');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36q (F7) — per-hop chain `model` field overrides IR model on fallback hop', async () => {
|
||||||
|
// Pre-D75: executeHopFn(provider, model, irReq) used `model` for cache key
|
||||||
|
// + audit but passed irReq UNCHANGED to provider.spawn() → the chain
|
||||||
|
// [{anthropic, claude-X}, {openai, gpt-5.5}]
|
||||||
|
// would spawn codex with --model claude-X (the user's original request)
|
||||||
|
// instead of gpt-5.5 (the chain's per-hop config). This broke cross-provider
|
||||||
|
// fallback wherever the chain assigned different models per provider.
|
||||||
|
//
|
||||||
|
// Strategy: inject a mock openai provider that captures the IR it receives.
|
||||||
|
// Force the anthropic hop to fail with SPAWN_FAILED so the chain advances
|
||||||
|
// to openai. Assert the mock saw model === hop's configured model.
|
||||||
|
|
||||||
|
const { __setAuthConfig: setAC36q, __resetAuthConfig: resetAC36q,
|
||||||
|
__setFallbackConfig: setFC36q, __resetFallbackConfig: resetFC36q,
|
||||||
|
createOlpServer, loadedProviders } = await import('./server.mjs');
|
||||||
|
|
||||||
|
setAC36q({ allow_anonymous: true });
|
||||||
|
|
||||||
|
// Capture for mock openai provider
|
||||||
|
let capturedIR = null;
|
||||||
|
const mockOpenAI = {
|
||||||
|
name: 'openai',
|
||||||
|
displayName: 'Mock OpenAI for F7',
|
||||||
|
contractVersion: '1.0',
|
||||||
|
models: ['gpt-5.5'],
|
||||||
|
auth: { type: 'oauth', storage: 'file', path: '/tmp/fake', refresh: 'auto' },
|
||||||
|
async * spawn(ir, _authContext) {
|
||||||
|
// Snapshot the model field as received
|
||||||
|
capturedIR = { model: ir.model };
|
||||||
|
yield { type: 'delta', role: 'assistant', content: 'mock-openai-response' };
|
||||||
|
yield { type: 'stop', finish_reason: 'stop' };
|
||||||
|
},
|
||||||
|
estimateCost: () => null,
|
||||||
|
quotaStatus: async () => null,
|
||||||
|
healthCheck: async () => ({ ok: true, latencyMs: 0 }),
|
||||||
|
hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, cacheable: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Mock anthropic provider that always fails immediately with SPAWN_FAILED
|
||||||
|
const mockAnthropic = {
|
||||||
|
name: 'anthropic',
|
||||||
|
displayName: 'Mock Anthropic for F7',
|
||||||
|
contractVersion: '1.0',
|
||||||
|
models: ['claude-sonnet-4-6'],
|
||||||
|
auth: { type: 'oauth', storage: 'file', path: '/tmp/fake', refresh: 'auto' },
|
||||||
|
async * spawn(_ir, _authContext) {
|
||||||
|
throw new ProviderError('mock anthropic always fails (F7 test)', 'SPAWN_FAILED');
|
||||||
|
// eslint-disable-next-line no-unreachable
|
||||||
|
yield { type: 'stop' };
|
||||||
|
},
|
||||||
|
estimateCost: () => null,
|
||||||
|
quotaStatus: async () => null,
|
||||||
|
healthCheck: async () => ({ ok: true, latencyMs: 0 }),
|
||||||
|
hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, cacheable: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Save + clear loadedProviders, install the mocks
|
||||||
|
const savedProviders = new Map(loadedProviders);
|
||||||
|
loadedProviders.clear();
|
||||||
|
loadedProviders.set('anthropic', mockAnthropic);
|
||||||
|
loadedProviders.set('openai', mockOpenAI);
|
||||||
|
|
||||||
|
// 2-hop chain with DIFFERENT models per hop — the bug only manifests when
|
||||||
|
// hopModel !== requested model on the hop that actually serves.
|
||||||
|
setFC36q({
|
||||||
|
chains: {
|
||||||
|
'claude-sonnet-4-6': [
|
||||||
|
{ provider: 'anthropic', model: 'claude-sonnet-4-6' },
|
||||||
|
{ provider: 'openai', model: 'gpt-5.5' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
soft_triggers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const srv = createOlpServer();
|
||||||
|
let port = 28400 + Math.floor(Math.random() * 100);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
srv.listen(port, '127.0.0.1', resolve);
|
||||||
|
srv.once('error', e => {
|
||||||
|
if (e.code === 'EADDRINUSE') { port++; srv.listen(port, '127.0.0.1', resolve); srv.once('error', reject); }
|
||||||
|
else reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch({
|
||||||
|
port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
body: {
|
||||||
|
model: 'claude-sonnet-4-6',
|
||||||
|
messages: [{ role: 'user', content: 'F7 test' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200, `expected 200, got ${r.status}: ${r.body.slice(0, 300)}`);
|
||||||
|
assert.equal(r.headers['x-olp-provider-used'], 'openai',
|
||||||
|
'openai must be the serving hop (anthropic failed)');
|
||||||
|
// The F7 assertion: the openai hop received the chain's configured model,
|
||||||
|
// NOT the user's original request model.
|
||||||
|
assert.ok(capturedIR, 'mock openai must have been invoked');
|
||||||
|
assert.equal(capturedIR.model, 'gpt-5.5',
|
||||||
|
`F7: openai hop must receive its chain-configured model 'gpt-5.5', got '${capturedIR.model}'`);
|
||||||
|
} finally {
|
||||||
|
await new Promise(r => srv.close(r));
|
||||||
|
resetFC36q();
|
||||||
|
// Restore original providers
|
||||||
|
loadedProviders.clear();
|
||||||
|
for (const [k, v] of savedProviders) loadedProviders.set(k, v);
|
||||||
|
resetAC36q();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36r (F7) — per-hop model override preserved on streaming single-hop path', async () => {
|
||||||
|
// The streaming path (server.mjs ~line 1390-1434) has its own spawn call site
|
||||||
|
// inside sourceFactory that previously also passed `ir` unchanged. F7 fix
|
||||||
|
// wraps it with the same { ...ir, model: streamModel } substitution. This
|
||||||
|
// test exercises a single-hop streaming request and asserts the mock saw
|
||||||
|
// the chain's configured model.
|
||||||
|
|
||||||
|
const { __setAuthConfig: setAC36r, __resetAuthConfig: resetAC36r,
|
||||||
|
__setFallbackConfig: setFC36r, __resetFallbackConfig: resetFC36r,
|
||||||
|
createOlpServer, loadedProviders } = await import('./server.mjs');
|
||||||
|
|
||||||
|
setAC36r({ allow_anonymous: true });
|
||||||
|
|
||||||
|
let capturedModel = null;
|
||||||
|
const mockOpenAIStream = {
|
||||||
|
name: 'openai',
|
||||||
|
displayName: 'Mock Codex for F7 streaming',
|
||||||
|
contractVersion: '1.0',
|
||||||
|
models: ['gpt-5.5'],
|
||||||
|
auth: { type: 'oauth', storage: 'file', path: '/tmp/fake', refresh: 'auto' },
|
||||||
|
async * spawn(ir, _authContext) {
|
||||||
|
capturedModel = ir.model;
|
||||||
|
yield { type: 'delta', role: 'assistant', content: 'streamed-content' };
|
||||||
|
yield { type: 'stop', finish_reason: 'stop' };
|
||||||
|
},
|
||||||
|
estimateCost: () => null,
|
||||||
|
quotaStatus: async () => null,
|
||||||
|
healthCheck: async () => ({ ok: true, latencyMs: 0 }),
|
||||||
|
hints: { requiresTTY: false, concurrentSpawnSafe: true, maxConcurrent: 4, cacheable: true },
|
||||||
|
};
|
||||||
|
|
||||||
|
const savedProviders = new Map(loadedProviders);
|
||||||
|
loadedProviders.clear();
|
||||||
|
loadedProviders.set('openai', mockOpenAIStream);
|
||||||
|
|
||||||
|
// Single-hop chain with a DIFFERENT model than the request — the bug is
|
||||||
|
// visible only when the user requests one model name and the chain remaps it.
|
||||||
|
setFC36r({
|
||||||
|
chains: {
|
||||||
|
'gpt-aliased': [
|
||||||
|
{ provider: 'openai', model: 'gpt-5.5' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
soft_triggers: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
const srv = createOlpServer();
|
||||||
|
let port = 28500 + Math.floor(Math.random() * 100);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
srv.listen(port, '127.0.0.1', resolve);
|
||||||
|
srv.once('error', e => {
|
||||||
|
if (e.code === 'EADDRINUSE') { port++; srv.listen(port, '127.0.0.1', resolve); srv.once('error', reject); }
|
||||||
|
else reject(e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const r = await fetch({
|
||||||
|
port,
|
||||||
|
method: 'POST',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
body: {
|
||||||
|
model: 'gpt-aliased',
|
||||||
|
stream: true,
|
||||||
|
messages: [{ role: 'user', content: 'F7 streaming test' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 200, `expected 200, got ${r.status}: ${r.body.slice(0, 200)}`);
|
||||||
|
assert.equal(capturedModel, 'gpt-5.5',
|
||||||
|
`F7 streaming: openai must receive chain-configured model 'gpt-5.5', got '${capturedModel}'`);
|
||||||
|
} finally {
|
||||||
|
await new Promise(r => srv.close(r));
|
||||||
|
resetFC36r();
|
||||||
|
loadedProviders.clear();
|
||||||
|
for (const [k, v] of savedProviders) loadedProviders.set(k, v);
|
||||||
|
resetAC36r();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── F5 (D76 v0.4.3): OLP_BIND env honored + safety warn ──────────────────
|
||||||
|
it('36s (F5) — server.mjs reads OLP_BIND with safe default 127.0.0.1', () => {
|
||||||
|
// F5 fix: previously bind was hard-coded `server.listen(PORT, '127.0.0.1', ...)`.
|
||||||
|
// D76 adds `const BIND = process.env.OLP_BIND ?? '127.0.0.1'` and the listen
|
||||||
|
// call uses BIND. Pin via source grep so a future refactor that re-introduces
|
||||||
|
// a hardcoded literal in the listen call fails this test.
|
||||||
|
const serverSrc = _readFileSyncS36(_joinS36(import.meta.dirname ?? process.cwd(), 'server.mjs'), 'utf8');
|
||||||
|
assert.ok(/process\.env\.OLP_BIND\s*\?\?\s*['"]127\.0\.0\.1['"]/.test(serverSrc),
|
||||||
|
'server.mjs must read OLP_BIND env with 127.0.0.1 default');
|
||||||
|
assert.ok(/server\.listen\(PORT,\s*BIND\b/.test(serverSrc),
|
||||||
|
'server.mjs server.listen must use the BIND variable (not a hardcoded address)');
|
||||||
|
assert.ok(!/server\.listen\(PORT,\s*['"]127\.0\.0\.1['"]/.test(serverSrc),
|
||||||
|
'server.mjs must NOT pass a hardcoded 127.0.0.1 literal to server.listen anymore');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36t (F5) — anonymous_key_advertised_with_lan_bind startup warn wiring', () => {
|
||||||
|
// Per ADR 0011 Deployment configurations amendment: when OLP_BIND is
|
||||||
|
// non-loopback AND advertise_anonymous_key is true, server emits a startup
|
||||||
|
// warn so the operator sees the trust-context overlap. Pin the wiring.
|
||||||
|
const serverSrc = _readFileSyncS36(_joinS36(import.meta.dirname ?? process.cwd(), 'server.mjs'), 'utf8');
|
||||||
|
assert.ok(/anonymous_key_advertised_with_lan_bind/.test(serverSrc),
|
||||||
|
'startup warn event name must be present');
|
||||||
|
// Loopback check must compare BIND against ALL three loopback forms
|
||||||
|
assert.ok(/BIND\s*!==\s*['"]127\.0\.0\.1['"][\s\S]{0,200}BIND\s*!==\s*['"]localhost['"][\s\S]{0,200}BIND\s*!==\s*['"]::1['"]/.test(serverSrc),
|
||||||
|
'startup warn must check BIND against all three loopback forms (127.0.0.1 / localhost / ::1)');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('36u (F5) — ADR 0011 Deployment configurations amendment present', () => {
|
||||||
|
const adrPath = _joinS36(import.meta.dirname ?? process.cwd(), 'docs/adr/0011-anonymous-key-deployment-context.md');
|
||||||
|
const adrSrc = _readFileSyncS36(adrPath, 'utf8');
|
||||||
|
assert.ok(/Deployment configurations \(D76 amendment/.test(adrSrc),
|
||||||
|
'ADR 0011 must carry the D76 amendment heading');
|
||||||
|
assert.ok(/OLP_BIND/.test(adrSrc), 'amendment must document OLP_BIND');
|
||||||
|
assert.ok(/anonymous_key_advertised_with_lan_bind/.test(adrSrc),
|
||||||
|
'amendment must cite the startup-warn event name');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user