mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-22 13:35:08 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69688a46c7 | ||
|
|
26b116deb8 | ||
|
|
b7f5f4d144 | ||
|
|
a90f830b5d |
@@ -24,7 +24,7 @@ One proxy. Multiple IDEs. All models. **$0 API cost.**
|
||||
|
||||
There are several Claude proxy projects. OCP picks a specific lane: **align tightly with what `cli.js` actually does, observe + multiplex what's already there, don't extend the protocol.** What you get:
|
||||
|
||||
- **LAN multi-user keys** (v3.7.0) — share one Claude Pro/Max subscription with family, friends, or your own devices. Each user gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation.
|
||||
- **LAN multi-user keys** (v3.7.0) — reach one Claude Pro/Max subscription from your own devices across the LAN. Each device gets a per-key API token (no OAuth session leak), with independent usage tracking and one-line revocation. Pro/Max are **per-user** accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other **people**.
|
||||
- **`ocp-connect` one-shot IDE setup** — one command on the client machine detects and configures Claude Code, Cursor, Cline, Continue.dev, OpenCode, and OpenClaw. No pasting `OPENAI_BASE_URL` six times.
|
||||
- **Response cache with per-key isolation + singleflight** (v3.13.0). Optional SHA-256 prompt cache, isolated per API key (cross-user pollution is impossible by hash construction, not by application logic), with stampede protection on concurrent identical prompts. Off by default. ([PR #65](https://github.com/dtzp555-max/ocp/pull/65), [PR #66](https://github.com/dtzp555-max/ocp/pull/66))
|
||||
- **Per-key request quotas** (v3.8.0). Daily / weekly / monthly limits per key — set a kid's iPad to 20/day, a partner's laptop to 100/week. ([PR #18](https://github.com/dtzp555-max/ocp/pull/18))
|
||||
@@ -49,7 +49,7 @@ OCP and the alternatives serve adjacent but distinct needs. Pick the one that fi
|
||||
| GitHub stars / ecosystem size | small | large | mid |
|
||||
| Governance discipline (CI-enforced alignment with cli.js) | yes | n/a | n/a |
|
||||
|
||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to share one Claude Pro/Max subscription across IDEs, devices, and people, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
**Plain English**: `claude-code-router` is the routing-and-switching power tool — pick it if you want to mix Anthropic, OpenAI, Gemini, and local models behind one endpoint. `anthropic-proxy` is the minimal forwarder. **OCP focuses on disciplined `cli.js`-aligned forwarding plus subscription multiplexing** — pick it if you want to reach one Claude Pro/Max subscription from your own IDEs and devices, with LAN auth, quotas, and a governance contract that prevents endpoint drift.
|
||||
|
||||
### Related: OLP — Open LLM Proxy
|
||||
|
||||
@@ -215,7 +215,7 @@ After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either
|
||||
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
||||
```
|
||||
|
||||
**LAN mode** — share with other devices on your network:
|
||||
**LAN mode** — reach OCP from your own devices on the network (Claude Pro/Max are per-user accounts — see [Sharing with family / a team — honest limits](#deployment-model--security-read-this) before extending access to other people):
|
||||
```bash
|
||||
# Enable LAN access with per-user auth (recommended)
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
|
||||
+15
-2
@@ -173,6 +173,9 @@ export function parseDeltaChunk(text, consumed = 0) {
|
||||
// fails the turn loudly (SSE error frame, no cache, counted on /health). Fail-loud is
|
||||
// the correct posture — a proxy that silently serves text the transcript disagrees with
|
||||
// is the exact class of bug ALIGNMENT.md exists to prevent.
|
||||
// Sentinel for "no message seen yet". Deliberately not null/undefined — see the constructor.
|
||||
const NO_MESSAGE_YET = Symbol("no-message-yet");
|
||||
|
||||
export class TuiDeltaAssembler {
|
||||
constructor({ holdbackChars = DEFAULT_HOLDBACK_CHARS, detectError = detectTuiUpstreamError } = {}) {
|
||||
this.holdbackChars = holdbackChars;
|
||||
@@ -180,7 +183,12 @@ export class TuiDeltaAssembler {
|
||||
this.emitted = ""; // bytes ALREADY written to the client — unretractable
|
||||
this.pending = ""; // held back, not yet written
|
||||
this.released = false;
|
||||
this.messageId = null;
|
||||
// NOT null: a payload may legitimately carry message_id === null, and if the sentinel were
|
||||
// also null the FIRST such payload would compare equal to it, register no boundary, and
|
||||
// leave `messages` at 0 — which used to disarm the restartedAfterEmit guard below entirely.
|
||||
// A unique object is === to nothing a JSON payload can produce, so the first fire ALWAYS
|
||||
// registers as message 1, whatever its message_id is (or isn't).
|
||||
this.messageId = NO_MESSAGE_YET;
|
||||
this.deltas = 0; // hook fires seen
|
||||
this.messages = 0; // distinct message_ids seen
|
||||
this.restartedAfterEmit = false;
|
||||
@@ -198,7 +206,12 @@ export class TuiDeltaAssembler {
|
||||
this.messages++;
|
||||
if (this.emitted === "") {
|
||||
this.pending = ""; // safe: the transcript will drop this message too
|
||||
} else if (this.messages > 1) {
|
||||
} else {
|
||||
// A boundary while bytes are ALREADY out is unrecoverable, full stop — the count of
|
||||
// messages seen so far is irrelevant. The old `else if (this.messages > 1)` guard was
|
||||
// the sole reason a null-message_id first payload could disarm F1: it left `messages`
|
||||
// at 0, so the real boundary evaluated 1 > 1 === false and never armed. The invariant
|
||||
// is "a boundary occurred while emitted !== ''", and that is exactly what this says.
|
||||
this.restartedAfterEmit = true; // unrecoverable — finalize() will refuse the turn
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3598,6 +3598,26 @@ test("F1: an auth banner rendered as a LATER message is never forwarded to the c
|
||||
assert.equal(a.finalize(BANNER).ok, false, "and the turn is refused, not served");
|
||||
});
|
||||
|
||||
test("F1: a first payload with message_id:null cannot disarm the guard", () => {
|
||||
// The residual bypass the reviewer found by probing. `this.messageId` used to be initialized
|
||||
// to null, so a first payload carrying message_id:null compared EQUAL to it → no boundary
|
||||
// registered → `messages` stayed 0 → when the REAL boundary arrived, `messages > 1` evaluated
|
||||
// 1 > 1 === false → restartedAfterEmit never armed → the released branch forwarded the banner.
|
||||
// The whole F1 guard was disarmed by a single null field. parseDeltaChunk does not validate
|
||||
// message_id, so such a payload does reach push().
|
||||
const a = new TuiDeltaAssembler({ holdbackChars: 100 });
|
||||
const narration = "I'll check that file for you and then report back with what I find inside it.";
|
||||
a.push({ hook_event_name: "MessageDisplay", message_id: null, delta: narration + narration });
|
||||
assert.notEqual(a.emitted, "", "precondition: the narration released to the client");
|
||||
assert.equal(a.messages, 1, "a null message_id is still a MESSAGE — it must register as one");
|
||||
|
||||
const BANNER = "Please run /login · API Error: 401 Invalid authentication credentials";
|
||||
const out = a.push({ hook_event_name: "MessageDisplay", message_id: "m2", delta: BANNER });
|
||||
assert.equal(out, null, "the banner must not be forwarded — the guard must arm regardless");
|
||||
assert.equal(a.restartedAfterEmit, true);
|
||||
assert.ok(!a.emitted.includes("401"));
|
||||
});
|
||||
|
||||
test("F1: whitespace cannot buy a release — the holdback screens TRIMMED length", () => {
|
||||
// detectTuiUpstreamError() TRIMS before applying its <=100-char rule, so gating release on
|
||||
// the UNTRIMMED pending.length let 101 spaces trim to "" → the detector has nothing to
|
||||
|
||||
Reference in New Issue
Block a user