mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7cff33cc18 | ||
|
|
d4f4aaf33a | ||
|
|
22806bffb5 | ||
|
|
6bfffd2cba | ||
|
|
2853088261 | ||
|
|
fd7973addb | ||
|
|
b908fec7b4 | ||
|
|
97ca91341c | ||
|
|
f3745fa8fe | ||
|
|
d71e0455e3 | ||
|
|
38070fbabb | ||
|
|
2adea6368d | ||
|
|
7860f71943 | ||
|
|
e326cee9dd | ||
|
|
f8ca9b85b0 | ||
|
|
c22b0dd074 | ||
|
|
7f46405152 | ||
|
|
cb6c2a8b5f | ||
|
|
02c6758a61 | ||
|
|
6d0e43ec37 | ||
|
|
b87992fa3b | ||
|
|
69b20815fa | ||
|
|
3eecca35ce | ||
|
|
9f1a21f7ad | ||
|
|
a0f9268af5 | ||
|
|
087e26346f | ||
|
|
ed53c5e0c5 | ||
|
|
18cb759359 | ||
|
|
cc745aa5d9 | ||
|
|
1983f9372d | ||
|
|
471bda2c40 | ||
|
|
566a01a6bd | ||
|
|
43daf8162c | ||
|
|
aa0adab784 | ||
|
|
4f4e1edf16 | ||
|
|
ea57db6ceb | ||
|
|
5fbeaed568 | ||
|
|
2b18884d2a | ||
|
|
4f72f4844e | ||
|
|
bc17b27f2b | ||
|
|
3e8ff7a509 | ||
|
|
b6b33d2623 | ||
|
|
8796a5fc19 | ||
|
|
609ceb28b0 | ||
|
|
3843ec8fe6 | ||
|
|
afe0c6e1be | ||
|
|
a6007393a3 | ||
|
|
8592150f7a | ||
|
|
eb76971ffc | ||
|
|
d54e73ef89 | ||
|
|
3f10b459f5 |
@@ -0,0 +1,40 @@
|
||||
# Pull Request
|
||||
|
||||
## Summary
|
||||
|
||||
<!-- One or two sentences describing the change and why it is in scope for OCP. -->
|
||||
|
||||
## Claude Code Alignment Evidence (REQUIRED)
|
||||
|
||||
Per `ALIGNMENT.md`, every PR that touches `server.mjs` or any network-facing surface must fill out this section. PRs with this section blank or unchecked will receive a `request changes` review and cannot be merged.
|
||||
|
||||
- [ ] **Corresponding `cli.js` reference.** I have identified the `cli.js` function and line range that performs the operation this PR forwards. Citation (format `cli.js:NNNN` or `cli.js vE4 <functionName>`):
|
||||
<!-- e.g. cli.js:18423-18467 (function: sendUserMessage) -->
|
||||
|
||||
- [ ] **If `cli.js` does not perform this operation**, I have stated this explicitly below and justified the scope under `ALIGNMENT.md` Rule 2. (Note: in almost all cases this means the PR should be closed, not merged. Proxy layers do not invent endpoints.)
|
||||
<!-- Justification, if applicable. Empty is fine when cli.js does perform the operation. -->
|
||||
|
||||
- [ ] **Commit message citations.** Every "Claude Code uses X" or "cli.js uses X" assertion in every commit of this PR is immediately followed by a `cli.js:NNNN` or `cli.js vE4 <functionName>` citation. I have verified this by rereading each commit message.
|
||||
|
||||
## Type of change
|
||||
|
||||
- [ ] Bug fix (alignment with existing `cli.js` behavior)
|
||||
- [ ] Feature (new `cli.js` behavior now surfaced through OCP)
|
||||
- [ ] Refactor (no wire-level behavior change)
|
||||
- [ ] Deletion (unalignable feature removal per `ALIGNMENT.md` Unalignable Policy)
|
||||
- [ ] Documentation / governance
|
||||
|
||||
## Reviewer checklist
|
||||
|
||||
Reviewers: this section is for you, not the author. Do not approve until every box is checked.
|
||||
|
||||
- [ ] I opened `cli.js` at the cited line range and confirmed the operation matches.
|
||||
- [ ] I ran (or confirmed CI ran) `.github/workflows/alignment.yml` and it passed.
|
||||
- [ ] I am not the commit author of any commit in this PR (Iron Rule 10).
|
||||
- [ ] If the PR asserts scope without a `cli.js` citation, I confirmed the justification is sound per `ALIGNMENT.md` Rule 2.
|
||||
|
||||
## Related
|
||||
|
||||
- `ALIGNMENT.md` Rule(s) invoked: <!-- e.g. Rule 3 -->
|
||||
- Related issue / prior PR: <!-- #NNN -->
|
||||
- Historical lesson reference (if relevant): <!-- e.g. 2026-04-11 drift, b87992f -->
|
||||
@@ -0,0 +1,123 @@
|
||||
name: Alignment Guardrail
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- 'server.mjs'
|
||||
- '.github/workflows/alignment.yml'
|
||||
|
||||
jobs:
|
||||
blacklist:
|
||||
name: server.mjs blacklist (hard fail)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Scan server.mjs for hallucinated tokens
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f server.mjs ]; then
|
||||
echo "server.mjs not found; nothing to scan."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Known-hallucinated tokens. Extend only via an ALIGNMENT.md amendment PR.
|
||||
# Each token is matched as a fixed string against server.mjs only.
|
||||
BLACKLIST=(
|
||||
"api.anthropic.com/api/oauth/usage"
|
||||
)
|
||||
|
||||
FAIL=0
|
||||
for token in "${BLACKLIST[@]}"; do
|
||||
if sed "s|//.*\$||" server.mjs | grep -n -F "$token"; then
|
||||
echo "::error file=server.mjs::Blacklisted token '$token' detected in server.mjs."
|
||||
FAIL=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$FAIL" -ne 0 ]; then
|
||||
cat <<'EOF'
|
||||
|
||||
============================================================
|
||||
ALIGNMENT GUARDRAIL FAILURE
|
||||
============================================================
|
||||
server.mjs contains a token on the OCP alignment blacklist.
|
||||
|
||||
These tokens were introduced by LLM hallucinations and do
|
||||
not appear in cli.js at any shipped Claude Code version.
|
||||
See ALIGNMENT.md -> "Historical Lesson: The 2026-04-11 Drift"
|
||||
(commit b87992f) for the full incident record.
|
||||
|
||||
Required action:
|
||||
1. Remove the token from server.mjs.
|
||||
2. grep the reference cli.js for the operation you
|
||||
intended and cite the real line numbers.
|
||||
3. See ALIGNMENT.md Rules 1, 2, and 5.
|
||||
|
||||
Do not add allowlist entries to this workflow without an
|
||||
amendment PR to ALIGNMENT.md (see Amendment Procedure).
|
||||
============================================================
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Blacklist scan clean."
|
||||
|
||||
commit-citation:
|
||||
name: commit message citation (soft check)
|
||||
runs-on: ubuntu-latest
|
||||
# Soft check: reports a warning but does not block merge. Reviewers
|
||||
# are expected to enforce per CLAUDE.md. Escalate to hard-fail via
|
||||
# an ALIGNMENT.md amendment if drift recurs.
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- name: Checkout full history
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan PR commits for uncited assertions
|
||||
shell: bash
|
||||
env:
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${BASE_SHA:-}" ] || [ -z "${HEAD_SHA:-}" ]; then
|
||||
echo "No PR context; skipping."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Collect commit messages in range.
|
||||
MSGS="$(git log --format=%B "${BASE_SHA}..${HEAD_SHA}")"
|
||||
|
||||
# Look for assertions of the form "Claude Code uses ..." or
|
||||
# "cli.js uses ..." (case-insensitive). For each hit, require
|
||||
# a citation in the same commit message in one of the forms:
|
||||
# cli.js:NNNN (line-number citation)
|
||||
# cli.js vE4 <name> (version + function-name citation)
|
||||
# Absence of a citation is a soft finding.
|
||||
|
||||
WARN=0
|
||||
# Split log into per-commit blocks for precise matching.
|
||||
git log --format="%H" "${BASE_SHA}..${HEAD_SHA}" | while read -r sha; do
|
||||
BODY="$(git log -1 --format=%B "$sha")"
|
||||
if echo "$BODY" | grep -E -i -q '(claude[[:space:]]+code|cli\.js)[[:space:]]+uses'; then
|
||||
if echo "$BODY" | grep -E -q '(cli\.js:[0-9]+|cli\.js[[:space:]]+v[A-Za-z0-9]+[[:space:]]+[A-Za-z_][A-Za-z0-9_]*)'; then
|
||||
echo "OK $sha: assertion cited."
|
||||
else
|
||||
echo "::warning::Commit $sha asserts 'Claude Code uses ...' or 'cli.js uses ...' but does not cite a cli.js line number or versioned function name. See CLAUDE.md -> Commit message conventions."
|
||||
WARN=1
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$WARN" -ne 0 ]; then
|
||||
echo "Soft check raised warnings. Reviewer: please enforce per CLAUDE.md."
|
||||
else
|
||||
echo "Commit citation soft check clean."
|
||||
fi
|
||||
@@ -0,0 +1,91 @@
|
||||
# OCP Alignment Constitution
|
||||
|
||||
**Status:** Active. This document is the supreme source of truth for OCP scope decisions. Conflicts with other documents (README, issues, prior commit messages) resolve in favor of this file.
|
||||
|
||||
---
|
||||
|
||||
## Core Principle
|
||||
|
||||
OCP (Open Claude Proxy) is a **proxy layer** for the Claude Code CLI. It forwards, observes, and multiplexes the traffic that `cli.js` already emits. It is **not** an extension layer. If `cli.js` does not perform a given operation, or performs it differently, OCP does not invent one.
|
||||
|
||||
---
|
||||
|
||||
## Rules
|
||||
|
||||
1. **Rule 1 (Grep First).** Before adding, renaming, or changing any endpoint, header, parameter, or response shape, the author must `grep` the reference `cli.js` and record the exact line numbers in the commit message and PR body. An absent grep hit is itself a finding and must be declared.
|
||||
|
||||
2. **Rule 2 (No Invention).** OCP must not introduce endpoints, headers, request fields, or response fields that are not present in `cli.js`. Speculative "Claude Code probably uses X" statements are prohibited. If the behavior is not observable in `cli.js`, the feature is out of scope.
|
||||
|
||||
3. **Rule 3 (Match the Implementation).** When `cli.js` does perform a given operation, OCP must match it byte-for-byte on the wire: same path, same method, same headers (including casing and ordering constraints), same body schema, same auth scheme. Deviations require an explicit, reviewed exception recorded in this file.
|
||||
|
||||
4. **Rule 4 (Unalignable Features Are Deleted).** Any existing OCP feature that cannot be traced to a concrete `cli.js` reference is deleted. There is no "grandfathering" and no "keep it disabled." The policy is removal, not deprecation. See the Unalignable Policy section below.
|
||||
|
||||
5. **Rule 5 (Cite Line Numbers in Commits).** Every commit that touches `server.mjs` must reference `cli.js` by line number or function name in the form `cli.js:NNNN` or `cli.js vE4 <functionName>`. Commits asserting "Claude Code uses X" without such a citation are blocked by CI and must be reverted on detection.
|
||||
|
||||
---
|
||||
|
||||
## Golden Reference: `cli.js`
|
||||
|
||||
`cli.js` is the Claude Code CLI JavaScript bundle shipped inside the `@anthropic-ai/claude-code` npm package. It is the single source of truth for "what Claude Code actually does."
|
||||
|
||||
### Canonical paths per machine
|
||||
|
||||
| Machine / environment | Path |
|
||||
| --- | --- |
|
||||
| macOS (npm global) | `/usr/local/lib/node_modules/@anthropic-ai/claude-code/cli.js` |
|
||||
| macOS (nvm) | `~/.nvm/versions/node/*/lib/node_modules/@anthropic-ai/claude-code/cli.js` |
|
||||
| Linux (npm global) | `/usr/lib/node_modules/@anthropic-ai/claude-code/cli.js` |
|
||||
| Linux (OCI opc user) | `~/.npm-global/lib/node_modules/@anthropic-ai/claude-code/cli.js` |
|
||||
| Windows (npm global) | `%APPDATA%\npm\node_modules\@anthropic-ai\claude-code\cli.js` |
|
||||
| Raspberry Pi (nvm) | `~/.nvm/versions/node/*/lib/node_modules/@anthropic-ai/claude-code/cli.js` |
|
||||
|
||||
### Current audit pin
|
||||
|
||||
- **Claude Code version under audit:** `2.1.89`
|
||||
- **`cli.js` SHA-256:** `a9950ef6407fdc750bddb673852485500387e524a99d42385cb81e7d17128e01`
|
||||
- **Audit date:** `2026-04-20`
|
||||
- **Auditor:** `Tao Deng`
|
||||
|
||||
The audit pin is updated once per year (see Annual Alignment Audit) and whenever a drift incident forces a re-verification.
|
||||
|
||||
---
|
||||
|
||||
## Historical Lesson: The 2026-04-11 Drift
|
||||
|
||||
On 2026-04-11, commit `b87992f` ("fix: use dedicated /api/oauth/usage endpoint for reliable plan data") was merged. The commit message asserted that `/api/oauth/usage` was "the dedicated usage endpoint that Claude Code CLI uses."
|
||||
|
||||
**This assertion was false.** The string `/api/oauth/usage` does not appear in `cli.js` at any version shipped up to that date. The endpoint was fabricated by an LLM-assisted authoring pass that generalized from adjacent OAuth paths without verifying against `cli.js`. A follow-up commit `cb6c2a8` ("fallback to stale cache on usage API 429 + extend cache to 15min") compounded the error by caching the fabricated response to hide the 4xx failures.
|
||||
|
||||
**Impact:** The `/usage` progress bar in the dashboard was broken for nine days (2026-04-11 through 2026-04-20) before the drift was isolated.
|
||||
|
||||
**Root cause:** LLM hallucination accepted without `grep cli.js` verification, compounded by the absence of a CI blacklist and the absence of this constitution.
|
||||
|
||||
**Fix commit:** `fd7973a` (PR #21 — restored header-based `/usage`); follow-up `01e260c` (PR #24 — OAuth Bearer header correction)
|
||||
|
||||
**Lesson codified:** Rules 1, 2, and 5 of this document; the CI blacklist in `.github/workflows/alignment.yml`; and the PR template evidence section exist to make the 2026-04-11 drift structurally impossible to repeat.
|
||||
|
||||
---
|
||||
|
||||
## Unalignable Policy
|
||||
|
||||
A feature is **unalignable** if, after a good-faith search, it cannot be mapped to a specific `cli.js` line range or function.
|
||||
|
||||
- Unalignable features are **deleted**, not disabled, not feature-flagged, not deprecated.
|
||||
- Deletion is the default outcome of an alignment audit finding. The burden of proof is on the feature, not on the auditor.
|
||||
- A deletion PR does not require user-facing deprecation notice, because the feature was never legitimately in scope.
|
||||
- If a user workflow depended on an unalignable feature, the correct remediation is to upstream the behavior into `cli.js` or to move it out of OCP into a separate tool. OCP does not retain it.
|
||||
|
||||
---
|
||||
|
||||
## Annual Alignment Audit
|
||||
|
||||
- **Date:** 11 April each year (the anniversary of the `b87992f` drift).
|
||||
- **Scope:** Diff the current `cli.js` against the pinned SHA-256 in the Golden Reference section. For every network call in `server.mjs`, re-verify that the corresponding `cli.js` reference still exists at the cited line numbers (adjust citations if line numbers shifted across Claude Code versions).
|
||||
- **Output:** A signed audit note committed to `docs/alignment-audits/YYYY-04-11.md`, updating the pin.
|
||||
- **Failure mode:** Any audit finding that cannot be reconciled triggers an immediate deletion PR per the Unalignable Policy.
|
||||
|
||||
---
|
||||
|
||||
## Amendment Procedure
|
||||
|
||||
This constitution is amended only by a PR that (a) cites the evidence motivating the amendment, (b) is reviewed by an independent reviewer per CC Iron Rule 10, and (c) updates the Historical Lesson section if the amendment was driven by an incident. Amendments never retroactively legitimize previously unalignable features.
|
||||
@@ -0,0 +1,58 @@
|
||||
# OCP Project Session Instructions
|
||||
|
||||
> **WARNING — READ BEFORE WRITING ANY CODE IN THIS REPO**
|
||||
>
|
||||
> Before touching `server.mjs` or any network-facing surface, read [`./ALIGNMENT.md`](./ALIGNMENT.md) in full. The constitution is binding. Non-compliant commits are reverted.
|
||||
|
||||
---
|
||||
|
||||
## Before starting any task
|
||||
|
||||
1. Read `./ALIGNMENT.md`. Internalize the five Rules and the 2026-04-11 drift lesson.
|
||||
2. Run `/dev-start <task description>` to get a pre-flight plan that incorporates the iron rules, `SKILL_ROUTING.md`, this file, and `ALIGNMENT.md`.
|
||||
3. If the task touches `server.mjs`, locate the corresponding `cli.js` reference **before** drafting any code. No code is written ahead of the `grep cli.js` evidence.
|
||||
|
||||
---
|
||||
|
||||
## Hard requirements for `server.mjs` changes
|
||||
|
||||
Every PR that modifies `server.mjs` must satisfy all three of the following. A PR missing any one of them is blocked from merge.
|
||||
|
||||
1. **`cli.js` citation.** The commit message and PR body declare the corresponding `cli.js` function name and line number range, using the format `cli.js:NNNN` or `cli.js vE4 <functionName>`. If `cli.js` does not perform the operation, the PR must state this explicitly and justify scope under `ALIGNMENT.md` Rule 2 (in practice, this almost always means the PR should be closed).
|
||||
2. **CI blacklist pass.** The `alignment.yml` workflow must pass. The workflow greps `server.mjs` for known-hallucinated tokens (including `api/oauth/usage` and `api/usage`) and fails the build on any hit. Do not suppress the workflow. Do not add allowlist entries without an amendment PR to `ALIGNMENT.md`.
|
||||
3. **Independent reviewer (Iron Rule 10).** The implementation author may not self-approve. A separate reviewer — human or a subagent spawned with a fresh context — must read the diff, verify the `cli.js` citation by opening `cli.js` at the cited lines, and explicitly approve. A review comment that does not confirm the `cli.js` citation was checked is not a valid approval.
|
||||
|
||||
---
|
||||
|
||||
## Iron rules in force
|
||||
|
||||
This repo operates under the CC Development Iron Rules (CC 开发铁律) v1.3. Three rules are load-bearing for OCP work:
|
||||
|
||||
- **Iron Rule 10 (Code Review).** Every implementation phase has an independent reviewer. Self-review does not count. See `server.mjs` hard requirement #3 above.
|
||||
- **Iron Rule 11 (Incremental Diff Review).** Non-trivial work is split into the minimum reviewable unit — one PR per layer per severity. `ALIGNMENT.md`, `CLAUDE.md`, the PR template, and the CI workflow are therefore shipped as the same constitutional PR (they are one layer: governance), but any subsequent `server.mjs` remediation lands as its own PR.
|
||||
- **Iron Rule 12 (Pre-Brainstorm Prior-Art Search).** Before proposing any new endpoint or header, search GitHub, Anthropic docs, and the `cli.js` bundle. For OCP specifically, the `cli.js` grep is the decisive search: if it does not hit, Rule 2 of the constitution applies.
|
||||
|
||||
The full iron rules are at `~/.claude/CC_DEV_IRON_RULES.md` (symlinked from the cc-rules repo on Tao's workstations). Load them into session context with `/cc-rules` when needed.
|
||||
|
||||
---
|
||||
|
||||
## Skills relevant to this repo
|
||||
|
||||
- `/dev-start` — pre-flight planning, always first.
|
||||
- `/cc-rules` — load the iron rules into context.
|
||||
- `/agent-dispatch` — pick the correct model (opus for design and review, sonnet for straightforward edits, haiku for mechanical chores) before spawning any subagent.
|
||||
- `/cc-mem search <keyword>` — look up cross-machine memory for prior decisions, especially prior drift incidents.
|
||||
|
||||
---
|
||||
|
||||
## Commit message conventions
|
||||
|
||||
- Subject line uses Conventional Commits (`fix:`, `feat:`, `docs:`, `refactor:`, `chore:`).
|
||||
- Any assertion of the form "Claude Code uses X" or "cli.js uses X" in the body must be immediately followed by a citation in the form `cli.js:NNNN` or `cli.js vE4 <functionName>`. CI performs a soft check for this pattern on all commits in the PR.
|
||||
- Co-author trailer is required for LLM-assisted commits (`Co-Authored-By: Claude <model> <noreply@anthropic.com>`).
|
||||
|
||||
---
|
||||
|
||||
## Project-level escalation
|
||||
|
||||
If a design decision cannot be resolved by reference to `cli.js` and `ALIGNMENT.md`, escalate to Tao (老大) via `/cc-chat` rather than guessing. Silent guessing is what produced the 2026-04-11 drift.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Tao Deng
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,7 +1,5 @@
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
> **Status: Stable (v3.0.0)** — Feature-complete. Bug fixes only.
|
||||
|
||||
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||
|
||||
OCP turns your Claude Pro/Max subscription into a standard OpenAI-compatible API on localhost. Any tool that speaks the OpenAI protocol can use it — no separate API key, no extra billing.
|
||||
@@ -29,9 +27,34 @@ Any tool that accepts `OPENAI_BASE_URL` works with OCP:
|
||||
| **OpenClaw** | `setup.mjs` auto-configures |
|
||||
| **Any OpenAI client** | Set base URL to `http://127.0.0.1:3456/v1` |
|
||||
|
||||
## Quick Start
|
||||
## Installation
|
||||
|
||||
OCP has two roles: **Server** (runs the proxy, needs Claude CLI) and **Client** (connects to a server, zero dependencies).
|
||||
|
||||
```
|
||||
┌─ Server (always-on device) ─────────────────────────────┐
|
||||
│ Mac mini / NAS / Raspberry Pi / Desktop │
|
||||
│ Claude CLI + OCP server → bound to 0.0.0.0:3456 │
|
||||
└───────────────────────┬─────────────────────────────────┘
|
||||
│ LAN
|
||||
┌───────────────────┼───────────────────┐
|
||||
▼ ▼ ▼
|
||||
Laptop Phone/Tablet Pi / Server
|
||||
(client) (browser) (client)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Server Setup
|
||||
|
||||
> **Recommended:** Install OCP on a device that stays powered on — Mac mini, NAS, Raspberry Pi, or a desktop that doesn't sleep. This ensures all clients always have access.
|
||||
|
||||
**Prerequisites:**
|
||||
- Node.js 18+
|
||||
- [Claude CLI](https://docs.anthropic.com/en/docs/claude-cli) installed and authenticated (`claude auth login`)
|
||||
|
||||
```bash
|
||||
# 1. Clone and run setup
|
||||
git clone https://github.com/dtzp555-max/ocp.git
|
||||
cd ocp
|
||||
node setup.mjs
|
||||
@@ -41,20 +64,243 @@ The setup script will:
|
||||
1. Verify Claude CLI is installed and authenticated
|
||||
2. Start the proxy on port 3456
|
||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
||||
4. Symlink `ocp` to `/usr/local/bin` for CLI access
|
||||
|
||||
Then point your IDE to the proxy:
|
||||
|
||||
**Single-machine use** — just set your IDE to use the proxy:
|
||||
```bash
|
||||
export OPENAI_BASE_URL=http://127.0.0.1:3456/v1
|
||||
```
|
||||
|
||||
### Verify
|
||||
**LAN mode** — share with other devices on your network:
|
||||
```bash
|
||||
# Enable LAN access with per-user auth (recommended)
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
```
|
||||
|
||||
Then create API keys for each person/device:
|
||||
```bash
|
||||
export OCP_ADMIN_KEY=your-secret-admin-key
|
||||
|
||||
ocp keys add wife-laptop
|
||||
# ✓ Key created for "wife-laptop"
|
||||
# API Key: ocp_xDYzOB9ZKYzn...
|
||||
# Copy this key now — you won't see it again.
|
||||
|
||||
ocp keys add son-ipad
|
||||
ocp keys add pi-server
|
||||
```
|
||||
|
||||
Run `ocp lan` to see your IP and ready-to-share instructions.
|
||||
|
||||
**Verify:**
|
||||
```bash
|
||||
curl http://127.0.0.1:3456/v1/models
|
||||
# Returns: claude-opus-4-6, claude-sonnet-4-6, claude-haiku-4
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Client Setup
|
||||
|
||||
> Clients do **not** need to install Node.js, Claude CLI, or the OCP repo. Only `curl` and `python3` are required (pre-installed on most Linux/Mac systems).
|
||||
|
||||
**One-command setup** — download the lightweight `ocp-connect` script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||
chmod +x ocp-connect
|
||||
./ocp-connect <server-ip>
|
||||
```
|
||||
|
||||
**Zero-config** — when the server admin has set `PROXY_ANONYMOUS_KEY` (see [Anonymous Access](#anonymous-access-optional) below), just pass the server IP and nothing else. `ocp-connect` reads the anonymous key from `/health` and uses it automatically:
|
||||
|
||||
```bash
|
||||
./ocp-connect <server-ip>
|
||||
```
|
||||
|
||||
If the server requires a key, pass it with `--key`:
|
||||
```bash
|
||||
./ocp-connect <server-ip> --key <your-api-key>
|
||||
```
|
||||
|
||||
Or as a one-liner (no file saved):
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip>
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
$ ./ocp-connect 192.168.1.100
|
||||
|
||||
OCP Connect v1.3.0
|
||||
─────────────────────────────────────
|
||||
Remote: http://192.168.1.100:3456
|
||||
|
||||
Checking connectivity...
|
||||
✓ Connected
|
||||
|
||||
Remote OCP v3.9.0 (auth: multi)
|
||||
|
||||
ⓘ Using server-advertised anonymous key: ocp_publ...n_v1
|
||||
(set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)
|
||||
|
||||
Testing API access...
|
||||
✓ API accessible (3 models available)
|
||||
|
||||
Shell config:
|
||||
✓ .bashrc
|
||||
✓ .zshrc
|
||||
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
|
||||
|
||||
System-level (launchctl):
|
||||
✓ OPENAI_BASE_URL set for GUI apps and daemons
|
||||
|
||||
IDE Configuration
|
||||
─────────────────────────────────────
|
||||
Detected: OpenClaw (~/.openclaw/openclaw.json)
|
||||
|
||||
Configure OpenClaw to use this OCP? [Y/n] y
|
||||
Provider name (models show as <name>/model-id) [ocp]: ocp
|
||||
|
||||
How should OCP models be configured?
|
||||
1) Primary — use OCP by default, keep existing models as backup
|
||||
2) Backup — keep current primary, add OCP as additional option
|
||||
|
||||
Choice [1]: 1
|
||||
|
||||
Writing OpenClaw config...
|
||||
✓ Per-agent auth profile seeded (2):
|
||||
• ~/.openclaw/agents/main/agent/auth-profiles.json
|
||||
• ~/.openclaw/agents/macbook_bot/agent/auth-profiles.json
|
||||
✓ OpenClaw configured
|
||||
Provider: ocp
|
||||
Models:
|
||||
• ocp/claude-opus-4-6
|
||||
• ocp/claude-sonnet-4-6
|
||||
• ocp/claude-haiku-4-5-20251001
|
||||
Priority: PRIMARY (default model)
|
||||
|
||||
Restart OpenClaw to apply: openclaw gateway restart
|
||||
|
||||
Running smoke test...
|
||||
✓ Smoke test passed: OK
|
||||
Note: smoke test only verifies OCP is reachable and the key is valid.
|
||||
It does not verify your IDE/agent end-to-end. To verify OpenClaw works,
|
||||
restart it (`openclaw gateway restart`) and send a test message to your bot.
|
||||
|
||||
Done. Reload your shell to apply:
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
The script automatically:
|
||||
- Writes env vars to all relevant shell rc files (`.bashrc`, `.zshrc`)
|
||||
- Sets system-level env vars (`launchctl setenv` on macOS, `environment.d` on Linux)
|
||||
- **Auto-discovers anonymous key** from `/health.anonymousKey` when no `--key` given (v1.3.0+, requires server v3.9.0+)
|
||||
- Configures OpenClaw automatically (including per-agent `auth-profiles.json` for multi-agent setups)
|
||||
- Detects Cline, Continue.dev, Cursor, and opencode, and prints setup hints (manual configuration required for these IDEs)
|
||||
|
||||
On macOS, `launchctl setenv` vars reset on reboot — re-run `ocp-connect` after restart.
|
||||
|
||||
**Manual setup** — if you prefer not to use the script:
|
||||
```bash
|
||||
export OPENAI_BASE_URL=http://<server-ip>:3456/v1
|
||||
export OPENAI_API_KEY=ocp_<your-key>
|
||||
```
|
||||
Add these lines to `~/.bashrc` or `~/.zshrc` to persist across sessions.
|
||||
|
||||
---
|
||||
|
||||
### Monitoring (Server-side)
|
||||
|
||||
```bash
|
||||
# Per-key usage stats
|
||||
ocp usage --by-key
|
||||
# Key Reqs OK Err Avg Time
|
||||
# wife-laptop 5 5 0 8.0s
|
||||
# son-ipad 3 3 0 6.2s
|
||||
|
||||
# Manage keys
|
||||
ocp keys # List all keys
|
||||
ocp keys revoke son-ipad # Revoke a key
|
||||
```
|
||||
|
||||
**Web Dashboard:** Open `http://<server-ip>:3456/dashboard` in any browser for real-time monitoring — per-key usage, request history, plan utilization, and system health.
|
||||
|
||||

|
||||
|
||||
### Auth Modes
|
||||
|
||||
| Mode | Env | Use Case |
|
||||
|------|-----|----------|
|
||||
| `none` | `CLAUDE_AUTH_MODE=none` | Trusted home network, no auth needed |
|
||||
| `shared` | `CLAUDE_AUTH_MODE=shared` + `PROXY_API_KEY=xxx` | Everyone shares one key |
|
||||
| `multi` | `CLAUDE_AUTH_MODE=multi` + `OCP_ADMIN_KEY=xxx` | Per-person keys with usage tracking (recommended) |
|
||||
|
||||
### Anonymous Access (optional)
|
||||
|
||||
In `multi` mode, the admin can designate a single well-known "anonymous" key that bypasses `validateKey()` and grants public read/write access. This is useful for letting LAN users (or clients like OpenClaw multi-agent setups) connect without individual per-user keys.
|
||||
|
||||
**Enable**:
|
||||
|
||||
```bash
|
||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||
ocp start # or however you start the server
|
||||
```
|
||||
|
||||
**Client side**: the anonymous key value is exposed via `GET /health` as the field `anonymousKey` (null when not set). Clients like `ocp-connect` can auto-discover and use it, so the end user doesn't need to get a personal key from the admin.
|
||||
|
||||
**Security note**: setting this env var is an **opt-in** to public access — anyone who can reach your OCP endpoint can use it, up to any rate limits you configure. Don't enable this on internet-exposed OCP instances without additional protection.
|
||||
|
||||
**Not a secret**: because `/health` is an unauthenticated endpoint, the anonymous key is **publicly readable** by anyone who can reach the server. That is intentional — the key exists so clients can self-configure without out-of-band coordination. Treat it as a convenience handle, not as an access credential.
|
||||
|
||||
### Per-Key Quota (Budget Control)
|
||||
|
||||
Prevent any single user from exhausting your subscription. Set daily, weekly, or monthly request limits per API key:
|
||||
|
||||
```bash
|
||||
# Set a daily limit of 50 requests for a key
|
||||
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
|
||||
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
|
||||
-d '{"daily": 50}'
|
||||
|
||||
# Set multiple limits at once
|
||||
curl -X PATCH http://127.0.0.1:3456/api/keys/son-ipad/quota \
|
||||
-H "Authorization: Bearer $OCP_ADMIN_KEY" \
|
||||
-d '{"daily": 20, "weekly": 100}'
|
||||
|
||||
# Check current quota + usage
|
||||
curl http://127.0.0.1:3456/api/keys/wife-laptop/quota
|
||||
# → { "daily": { "limit": 50, "used": 12 }, "weekly": { "limit": null, "used": 34 }, ... }
|
||||
|
||||
# Remove a limit (set to null)
|
||||
curl -X PATCH http://127.0.0.1:3456/api/keys/wife-laptop/quota \
|
||||
-d '{"daily": null}'
|
||||
```
|
||||
|
||||
When a key exceeds its quota, OCP returns HTTP 429 with a structured error:
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Quota exceeded: 50/50 requests (daily). Resets 6h 12m.",
|
||||
"type": "quota_exceeded",
|
||||
"quota": { "period": "daily", "limit": 50, "used": 50, "resetsIn": "6h 12m" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `null` = unlimited (default for all keys)
|
||||
- Only successful requests count toward quota
|
||||
- Admin and anonymous users are never subject to quotas
|
||||
- PATCH is a partial update — omitted fields are left unchanged
|
||||
|
||||
### Important Notes
|
||||
|
||||
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||
- `ocp usage` shows how much quota remains
|
||||
- Keys are stored in `~/.ocp/ocp.db` (SQLite, zero external dependencies)
|
||||
- Admin key is required for key management API endpoints
|
||||
- The dashboard (`/dashboard`) and health check (`/health`) are always public
|
||||
|
||||
## Built-in Usage Monitoring
|
||||
|
||||
Check your subscription usage from the terminal:
|
||||
@@ -85,8 +331,14 @@ Proxy: up 6h 32m | 23 reqs | 0 err | 0 timeout
|
||||
|
||||
```
|
||||
ocp usage Plan usage limits & model stats
|
||||
ocp usage --by-key Per-key usage breakdown (LAN mode)
|
||||
ocp status Quick overview
|
||||
ocp health Proxy diagnostics
|
||||
ocp keys List all API keys (multi mode)
|
||||
ocp keys add <name> Create a new API key
|
||||
ocp keys revoke <name> Revoke an API key
|
||||
ocp connect <ip> One-command LAN client setup
|
||||
ocp lan Show LAN connection info & IP
|
||||
ocp settings View tunable settings
|
||||
ocp settings <k> <v> Update a setting at runtime
|
||||
ocp logs [N] [level] Recent logs (default: 20, error)
|
||||
@@ -95,7 +347,8 @@ ocp sessions Active sessions
|
||||
ocp clear Clear all sessions
|
||||
ocp restart Restart proxy
|
||||
ocp restart gateway Restart gateway
|
||||
ocp version Show version
|
||||
ocp update Update to latest version
|
||||
ocp update --check Check for updates without applying
|
||||
ocp --help Command reference
|
||||
```
|
||||
|
||||
@@ -111,6 +364,16 @@ ocp --help
|
||||
|
||||
> **Cloud/Linux servers:** If `ocp: command not found`, the binary isn't in PATH. Full path: `~/.openclaw/projects/ocp/ocp`
|
||||
|
||||
### Self-Update
|
||||
|
||||
```bash
|
||||
# Check if a new version is available
|
||||
ocp update --check
|
||||
|
||||
# Pull latest, sync plugin, restart proxy — one command
|
||||
ocp update
|
||||
```
|
||||
|
||||
### Runtime Settings (No Restart Needed)
|
||||
|
||||
```
|
||||
@@ -121,6 +384,42 @@ $ ocp settings maxConcurrent 4
|
||||
✓ maxConcurrent = 4
|
||||
```
|
||||
|
||||
## Response Cache
|
||||
|
||||
OCP can cache responses to avoid redundant Claude CLI calls for identical prompts. This is useful during development when the same prompt is sent repeatedly.
|
||||
|
||||
**Enable** by setting `CLAUDE_CACHE_TTL` (in milliseconds):
|
||||
|
||||
```bash
|
||||
# Cache responses for 5 minutes
|
||||
export CLAUDE_CACHE_TTL=300000
|
||||
|
||||
# Or update at runtime (no restart)
|
||||
ocp settings cacheTTL 300000
|
||||
```
|
||||
|
||||
**How it works:**
|
||||
- Cache key = SHA-256 of `model` + `messages` + `temperature` + `max_tokens` + `top_p`
|
||||
- Cache hits return instantly — no Claude CLI process spawned
|
||||
- Works for both streaming and non-streaming requests
|
||||
- Multi-turn conversations (with `session_id`) are never cached
|
||||
- Expired entries are cleaned up automatically every 10 minutes
|
||||
|
||||
**Management:**
|
||||
```bash
|
||||
# View cache stats
|
||||
curl http://127.0.0.1:3456/cache/stats
|
||||
# → { "entries": 42, "totalHits": 156, "sizeBytes": 284000 }
|
||||
|
||||
# Clear all cached responses
|
||||
curl -X DELETE http://127.0.0.1:3456/cache
|
||||
|
||||
# Disable cache at runtime
|
||||
ocp settings cacheTTL 0
|
||||
```
|
||||
|
||||
Cache is **disabled by default** (`CLAUDE_CACHE_TTL=0`). All data is stored locally in `~/.ocp/ocp.db`.
|
||||
|
||||
## How It Works
|
||||
|
||||
```
|
||||
@@ -135,7 +434,7 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p
|
||||
|----------|-------|
|
||||
| `claude-opus-4-6` | Most capable, slower |
|
||||
| `claude-sonnet-4-6` | Good balance of speed/quality |
|
||||
| `claude-haiku-4` | Fastest, lightweight |
|
||||
| `claude-haiku-4-5-20251001` | Fastest, lightweight |
|
||||
|
||||
## API Endpoints
|
||||
|
||||
@@ -149,6 +448,13 @@ OCP translates OpenAI-compatible `/v1/chat/completions` requests into `claude -p
|
||||
| `/settings` | GET/PATCH | View or update settings at runtime |
|
||||
| `/logs` | GET | Recent log entries (`?n=20&level=error`) |
|
||||
| `/sessions` | GET/DELETE | List or clear active sessions |
|
||||
| `/dashboard` | GET | Web dashboard (always public) |
|
||||
| `/api/keys` | GET/POST | List or create API keys (admin only) |
|
||||
| `/api/keys/:id` | DELETE | Revoke an API key (admin only) |
|
||||
| `/api/keys/:id/quota` | GET/PATCH | View or set per-key quota (admin only) |
|
||||
| `/api/usage` | GET | Per-key usage stats (`?since=&until=&hours=&limit=`) |
|
||||
| `/cache/stats` | GET | Cache statistics (admin only) |
|
||||
| `/cache` | DELETE | Clear response cache (admin only) |
|
||||
|
||||
## OpenClaw Integration
|
||||
|
||||
@@ -157,6 +463,7 @@ OCP was originally built for [OpenClaw](https://github.com/openclaw/openclaw) an
|
||||
- **`setup.mjs`** auto-configures the `claude-local` provider in `openclaw.json`
|
||||
- **Gateway plugin** registers `/ocp` as a native slash command in Telegram/Discord
|
||||
- **Multi-agent** — 8 concurrent requests sharing one subscription
|
||||
- **No conflicts** — uses neutral service names (`dev.ocp.proxy` / `ocp-proxy`) that don't trigger OpenClaw's gateway-like service detection
|
||||
|
||||
### Install the Gateway Plugin
|
||||
|
||||
@@ -176,42 +483,74 @@ Add to `~/.openclaw/openclaw.json`:
|
||||
|
||||
Restart: `openclaw gateway restart`
|
||||
|
||||
### Telegram / Discord Usage
|
||||
|
||||
After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
||||
|
||||
```
|
||||
/ocp status — Quick overview
|
||||
/ocp usage — Plan usage limits & model stats
|
||||
/ocp models — Available models
|
||||
/ocp health — Proxy diagnostics
|
||||
/ocp keys — List all API keys (multi mode)
|
||||
/ocp keys add <name> — Create a new key
|
||||
/ocp keys revoke <name> — Revoke a key
|
||||
```
|
||||
|
||||
> **Note:** Terminal CLI uses `ocp <command>`, Telegram/Discord uses `/ocp <command>`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Requests fail or agents stuck
|
||||
|
||||
```bash
|
||||
# Clear sessions and restart
|
||||
ocp clear
|
||||
ocp restart
|
||||
|
||||
# If using OpenClaw gateway
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
### Usage shows "unknown"
|
||||
|
||||
Usually caused by an expired Claude CLI session. Fix:
|
||||
```bash
|
||||
claude auth login
|
||||
ocp restart
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CLAUDE_PROXY_PORT` | `3456` | Listen port |
|
||||
| `CLAUDE_BIND` | `127.0.0.1` | Bind address (`0.0.0.0` for LAN access) |
|
||||
| `CLAUDE_AUTH_MODE` | `none` | Auth mode: `none`, `shared`, or `multi` |
|
||||
| `OCP_ADMIN_KEY` | *(unset)* | Admin key for key management (multi mode) |
|
||||
| `CLAUDE_BIN` | *(auto-detect)* | Path to claude binary |
|
||||
| `CLAUDE_TIMEOUT` | `300000` | Overall request timeout (ms) |
|
||||
| `CLAUDE_FIRST_BYTE_TIMEOUT` | `90000` | Base first-byte timeout (ms) |
|
||||
| `CLAUDE_TIMEOUT` | `600000` | Request timeout (ms, default: 10 min) |
|
||||
| `CLAUDE_MAX_CONCURRENT` | `8` | Max concurrent claude processes |
|
||||
| `CLAUDE_MAX_PROMPT_CHARS` | `150000` | Prompt truncation limit (chars) |
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||
| `CLAUDE_CACHE_TTL` | `0` | Response cache TTL (ms, 0 = disabled). Set to e.g. `300000` for 5-min cache |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for API authentication |
|
||||
| `CLAUDE_NO_CONTEXT` | `false` | Suppress CLAUDE.md and auto-memory injection (pure API mode) |
|
||||
| `PROXY_API_KEY` | *(unset)* | Bearer token for shared-mode authentication |
|
||||
| `PROXY_ANONYMOUS_KEY` | *(unset)* | Well-known anonymous key allowlist (multi mode). When set, this exact string bypasses `validateKey()` and grants public access. Exposed via `/health.anonymousKey` so clients auto-discover. See [Anonymous Access](#anonymous-access-optional). |
|
||||
|
||||
## Security
|
||||
|
||||
- **Localhost only** — binds to `127.0.0.1`, not exposed to the network
|
||||
- **Bearer token auth (optional)** — set `PROXY_API_KEY` to require auth
|
||||
- **Localhost by default** — binds to `127.0.0.1`; set `CLAUDE_BIND=0.0.0.0` to enable LAN access
|
||||
- **3-tier auth** — `none` (trusted network), `shared` (single key), `multi` (per-user keys with usage tracking)
|
||||
- **Timing-safe key comparison** — prevents timing attacks on API keys and admin keys
|
||||
- **Admin-only key management** — creating, listing, and revoking keys requires the admin key
|
||||
- **Public endpoints** — `/health` and `/dashboard` are always accessible without auth
|
||||
- **No API keys needed** — authentication goes through Claude CLI's OAuth session
|
||||
- **Keys stored locally** — `~/.ocp/ocp.db` (SQLite), never sent to external services
|
||||
- **Auto-start** — launchd (macOS) / systemd (Linux)
|
||||
|
||||
## Known Issues
|
||||
|
||||
### `/ocp` command returns "Unknown skill: ocp" (OpenClaw only)
|
||||
|
||||
The `/ocp` plugin command may intermittently stop working in Telegram/Discord. This is caused by an OpenClaw gateway bug ([openclaw/openclaw#26895](https://github.com/openclaw/openclaw/issues/26895)).
|
||||
|
||||
**Workaround:**
|
||||
```
|
||||
/new
|
||||
/ocp restart
|
||||
```
|
||||
|
||||
**Important:** Do not add `ocp` to agent `skills` lists or place a `SKILL.md` in workspace skills — this creates a routing conflict.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
+247
@@ -0,0 +1,247 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>OCP Dashboard</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; background: #0f172a; color: #e2e8f0; padding: 1.5rem; }
|
||||
h1 { font-size: 1.5rem; margin-bottom: 1rem; color: #38bdf8; }
|
||||
h2 { font-size: 1.1rem; margin: 1.5rem 0 0.5rem; color: #94a3b8; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.card { background: #1e293b; border-radius: 8px; padding: 1rem; }
|
||||
.card .label { font-size: 0.75rem; color: #64748b; text-transform: uppercase; }
|
||||
.card .value { font-size: 1.5rem; font-weight: 700; margin-top: 0.25rem; }
|
||||
.card .sub { font-size: 0.8rem; color: #94a3b8; margin-top: 0.25rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||||
th, td { text-align: left; padding: 0.5rem 0.75rem; border-bottom: 1px solid #334155; font-size: 0.85rem; }
|
||||
th { color: #64748b; font-weight: 600; }
|
||||
.tag { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; }
|
||||
.tag-ok { background: #065f46; color: #6ee7b7; }
|
||||
.tag-err { background: #7f1d1d; color: #fca5a5; }
|
||||
.btn { background: #2563eb; color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
||||
.btn:hover { background: #1d4ed8; }
|
||||
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
|
||||
.btn-danger { background: #dc2626; }
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
input { background: #1e293b; border: 1px solid #334155; color: #e2e8f0; padding: 0.4rem 0.75rem; border-radius: 6px; font-size: 0.85rem; }
|
||||
.flex { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.mono { font-family: "SF Mono", "Fira Code", monospace; font-size: 0.8rem; }
|
||||
.bar-bg { background: #334155; border-radius: 4px; height: 8px; overflow: hidden; margin-top: 0.5rem; }
|
||||
.bar-fill { height: 100%; border-radius: 4px; transition: width 0.5s; }
|
||||
.bar-blue { background: #3b82f6; }
|
||||
.bar-amber { background: #f59e0b; }
|
||||
.bar-red { background: #ef4444; }
|
||||
#refresh-indicator { font-size: 0.75rem; color: #475569; }
|
||||
.section { margin-bottom: 2rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="flex" style="justify-content: space-between; margin-bottom: 1.5rem;">
|
||||
<h1>OCP Dashboard</h1>
|
||||
<div class="flex">
|
||||
<span id="refresh-indicator"></span>
|
||||
<button class="btn btn-sm" onclick="refreshAll()">Refresh</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid" id="status-cards"></div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Plan Usage</h2>
|
||||
<div class="grid" id="plan-cards"></div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Usage by Key</h2>
|
||||
<table id="key-usage-table">
|
||||
<thead><tr><th>Key</th><th>Requests</th><th>OK</th><th>Err</th><th>Avg Time</th><th>Last Request</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section" id="key-mgmt-section" style="display:none">
|
||||
<h2>API Keys</h2>
|
||||
<div class="flex" style="margin-bottom: 0.75rem;">
|
||||
<input type="text" id="new-key-name" placeholder="Key name (e.g. wife-laptop)">
|
||||
<button class="btn btn-sm" onclick="addKey()">Create Key</button>
|
||||
</div>
|
||||
<table id="keys-table">
|
||||
<thead><tr><th>Name</th><th>Key</th><th>Created</th><th>Status</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Recent Requests</h2>
|
||||
<table id="recent-table">
|
||||
<thead><tr><th>Time</th><th>Key</th><th>Model</th><th>Prompt</th><th>Response</th><th>Time</th><th>Status</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const BASE = window.location.origin;
|
||||
const headers = {};
|
||||
const urlToken = new URLSearchParams(window.location.search).get("token");
|
||||
const storedToken = urlToken || localStorage.getItem("ocp_token");
|
||||
if (urlToken) { localStorage.setItem("ocp_token", urlToken); history.replaceState(null, "", "/dashboard"); }
|
||||
if (storedToken) headers["Authorization"] = `Bearer ${storedToken}`;
|
||||
|
||||
async function api(path) {
|
||||
const resp = await fetch(BASE + path, { headers });
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
const token = prompt("Enter your OCP admin key:");
|
||||
if (token) {
|
||||
localStorage.setItem("ocp_token", token);
|
||||
headers["Authorization"] = `Bearer ${token}`;
|
||||
return api(path);
|
||||
}
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiPost(path, body) {
|
||||
const resp = await fetch(BASE + path, {
|
||||
method: "POST", headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function apiDelete(path) {
|
||||
const resp = await fetch(BASE + path, { method: "DELETE", headers });
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
function fmtTime(ms) {
|
||||
if (!ms) return "-";
|
||||
return ms > 1000 ? (ms/1000).toFixed(1) + "s" : Math.round(ms) + "ms";
|
||||
}
|
||||
|
||||
function fmtChars(n) {
|
||||
if (!n) return "-";
|
||||
return n > 1000 ? (n/1000).toFixed(0) + "K" : String(n);
|
||||
}
|
||||
|
||||
function barColor(pct) {
|
||||
if (pct >= 80) return "bar-red";
|
||||
if (pct >= 50) return "bar-amber";
|
||||
return "bar-blue";
|
||||
}
|
||||
|
||||
async function refreshStatus() {
|
||||
const data = await api("/status");
|
||||
const p = data.proxy || {};
|
||||
const r = data.requests || {};
|
||||
|
||||
document.getElementById("status-cards").innerHTML = `
|
||||
<div class="card"><div class="label">Status</div><div class="value"><span class="tag ${p.status === 'ok' ? 'tag-ok' : 'tag-err'}">${p.status || '?'}</span></div><div class="sub">v${p.version || '?'}</div></div>
|
||||
<div class="card"><div class="label">Uptime</div><div class="value">${p.uptime || '?'}</div></div>
|
||||
<div class="card"><div class="label">Requests</div><div class="value">${r.total || 0}</div><div class="sub">${r.active || 0} active</div></div>
|
||||
<div class="card"><div class="label">Errors</div><div class="value">${r.errors || 0}</div><div class="sub">${r.timeouts || 0} timeouts</div></div>
|
||||
<div class="card"><div class="label">Sessions</div><div class="value">${p.activeSessions || 0}</div></div>
|
||||
`;
|
||||
|
||||
const plan = data.plan;
|
||||
if (plan && typeof plan === 'object' && !plan.error) {
|
||||
const s = plan.currentSession || {};
|
||||
const w = plan.weeklyLimits?.allModels || {};
|
||||
const sPct = Math.round((s.utilization || 0) * 100);
|
||||
const wPct = Math.round((w.utilization || 0) * 100);
|
||||
document.getElementById("plan-cards").innerHTML = `
|
||||
<div class="card">
|
||||
<div class="label">Session (5h)</div>
|
||||
<div class="value">${s.percent || '?'}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(sPct)}" style="width:${sPct}%"></div></div>
|
||||
<div class="sub">Resets in ${s.resetsIn || '?'}</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="label">Weekly (7d)</div>
|
||||
<div class="value">${w.percent || '?'}</div>
|
||||
<div class="bar-bg"><div class="bar-fill ${barColor(wPct)}" style="width:${wPct}%"></div></div>
|
||||
<div class="sub">Resets in ${w.resetsIn || '?'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsage() {
|
||||
try {
|
||||
const data = await api("/api/usage");
|
||||
const tbody = document.querySelector("#key-usage-table tbody");
|
||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.key_name}</td>
|
||||
<td>${k.requests}</td>
|
||||
<td>${k.successes}</td>
|
||||
<td>${k.errors}</td>
|
||||
<td>${fmtTime(k.avg_elapsed_ms)}</td>
|
||||
<td class="mono">${k.last_request || '-'}</td>
|
||||
</tr>
|
||||
`).join("") || '<tr><td colspan="6" style="color:#475569">No usage data yet</td></tr>';
|
||||
|
||||
const rtbody = document.querySelector("#recent-table tbody");
|
||||
rtbody.innerHTML = (data.recent || []).slice(0, 20).map(r => `
|
||||
<tr>
|
||||
<td class="mono">${r.created_at?.slice(11, 19) || '?'}</td>
|
||||
<td>${r.key_name}</td>
|
||||
<td>${r.model}</td>
|
||||
<td>${fmtChars(r.prompt_chars)}</td>
|
||||
<td>${fmtChars(r.response_chars)}</td>
|
||||
<td>${fmtTime(r.elapsed_ms)}</td>
|
||||
<td><span class="tag ${r.success ? 'tag-ok' : 'tag-err'}">${r.success ? 'ok' : 'err'}</span></td>
|
||||
</tr>
|
||||
`).join("") || '<tr><td colspan="7" style="color:#475569">No requests yet</td></tr>';
|
||||
} catch(e) {
|
||||
console.warn("Usage API not available:", e);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshKeys() {
|
||||
try {
|
||||
const data = await api("/api/keys");
|
||||
document.getElementById("key-mgmt-section").style.display = "";
|
||||
const tbody = document.querySelector("#keys-table tbody");
|
||||
tbody.innerHTML = (data.keys || []).map(k => `
|
||||
<tr>
|
||||
<td>${k.name}</td>
|
||||
<td class="mono">${k.keyPreview}</td>
|
||||
<td class="mono">${k.created_at}</td>
|
||||
<td><span class="tag ${k.revoked ? 'tag-err' : 'tag-ok'}">${k.revoked ? 'revoked' : 'active'}</span></td>
|
||||
<td>${k.revoked ? '' : `<button class="btn btn-sm btn-danger" onclick="revokeKeyUI('${k.name}')">Revoke</button>`}</td>
|
||||
</tr>
|
||||
`).join("");
|
||||
} catch(e) { /* not admin */ }
|
||||
}
|
||||
|
||||
async function addKey() {
|
||||
const name = document.getElementById("new-key-name").value.trim();
|
||||
if (!name) return alert("Enter a key name");
|
||||
const result = await apiPost("/api/keys", { name });
|
||||
if (result.key) {
|
||||
alert("New API Key (copy it now — you won't see it again):\n\n" + result.key);
|
||||
document.getElementById("new-key-name").value = "";
|
||||
refreshKeys();
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeKeyUI(name) {
|
||||
if (!confirm(`Revoke key "${name}"?`)) return;
|
||||
await apiDelete(`/api/keys/${encodeURIComponent(name)}`);
|
||||
refreshKeys();
|
||||
}
|
||||
|
||||
async function refreshAll() {
|
||||
document.getElementById("refresh-indicator").textContent = "Refreshing...";
|
||||
await Promise.all([refreshStatus(), refreshUsage(), refreshKeys()]);
|
||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
refreshAll();
|
||||
setInterval(refreshAll, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,362 @@
|
||||
// keys.mjs — API key management and usage tracking for OCP LAN mode
|
||||
// Uses Node.js built-in SQLite (node:sqlite) — zero external dependencies.
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { randomBytes, createHash } from "node:crypto";
|
||||
import { join } from "node:path";
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
const OCP_DIR = join(homedir(), ".ocp");
|
||||
mkdirSync(OCP_DIR, { recursive: true });
|
||||
const DB_PATH = join(OCP_DIR, "ocp.db");
|
||||
|
||||
let db;
|
||||
|
||||
export function getDb() {
|
||||
if (!db) {
|
||||
db = new DatabaseSync(DB_PATH);
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
initSchema();
|
||||
}
|
||||
return db;
|
||||
}
|
||||
|
||||
function initSchema() {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS api_keys (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key TEXT UNIQUE NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS usage_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
key_id INTEGER,
|
||||
key_name TEXT NOT NULL DEFAULT 'anonymous',
|
||||
model TEXT NOT NULL,
|
||||
prompt_chars INTEGER NOT NULL DEFAULT 0,
|
||||
response_chars INTEGER NOT NULL DEFAULT 0,
|
||||
elapsed_ms INTEGER NOT NULL DEFAULT 0,
|
||||
success INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
FOREIGN KEY (key_id) REFERENCES api_keys(id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_created ON usage_log(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_key ON usage_log(key_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS response_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
hash TEXT UNIQUE NOT NULL,
|
||||
model TEXT NOT NULL,
|
||||
response TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
last_hit_at TEXT,
|
||||
hits INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_hash ON response_cache(hash);
|
||||
CREATE INDEX IF NOT EXISTS idx_cache_created ON response_cache(created_at);
|
||||
`);
|
||||
|
||||
// Idempotent migrations: add quota columns if they don't exist yet.
|
||||
for (const col of [
|
||||
"ALTER TABLE api_keys ADD COLUMN quota_daily INTEGER DEFAULT NULL",
|
||||
"ALTER TABLE api_keys ADD COLUMN quota_weekly INTEGER DEFAULT NULL",
|
||||
"ALTER TABLE api_keys ADD COLUMN quota_monthly INTEGER DEFAULT NULL",
|
||||
]) {
|
||||
try { db.exec(col); } catch (e) {
|
||||
// SQLite throws "duplicate column name" if already present — safe to ignore.
|
||||
if (!e.message?.includes("duplicate column")) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Key CRUD ──
|
||||
|
||||
export function createKey(name) {
|
||||
const key = "ocp_" + randomBytes(24).toString("base64url");
|
||||
const d = getDb();
|
||||
const stmt = d.prepare("INSERT INTO api_keys (key, name) VALUES (?, ?)");
|
||||
const result = stmt.run(key, name);
|
||||
return { id: result.lastInsertRowid, key, name };
|
||||
}
|
||||
|
||||
export function listKeys() {
|
||||
const d = getDb();
|
||||
return d.prepare(
|
||||
"SELECT id, key, name, created_at, revoked, quota_daily, quota_weekly, quota_monthly FROM api_keys ORDER BY created_at DESC"
|
||||
).all().map(({ key, ...rest }) => ({
|
||||
...rest,
|
||||
keyPreview: key.slice(0, 8) + "..." + key.slice(-4),
|
||||
}));
|
||||
}
|
||||
|
||||
export function revokeKey(idOrName) {
|
||||
const d = getDb();
|
||||
const stmt = d.prepare(
|
||||
"UPDATE api_keys SET revoked = 1 WHERE (id = ? OR name = ?) AND revoked = 0"
|
||||
);
|
||||
return stmt.run(idOrName, idOrName).changes > 0;
|
||||
}
|
||||
|
||||
export function validateKey(key) {
|
||||
const d = getDb();
|
||||
const row = d.prepare(
|
||||
"SELECT id, name FROM api_keys WHERE key = ? AND revoked = 0"
|
||||
).get(key);
|
||||
return row || null;
|
||||
}
|
||||
|
||||
// ── Usage recording ──
|
||||
|
||||
export function recordUsage({ keyId, keyName, model, promptChars, responseChars, elapsedMs, success }) {
|
||||
const d = getDb();
|
||||
d.prepare(`
|
||||
INSERT INTO usage_log (key_id, key_name, model, prompt_chars, response_chars, elapsed_ms, success)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(keyId ?? null, keyName || "anonymous", model, promptChars, responseChars, elapsedMs, success ? 1 : 0);
|
||||
}
|
||||
|
||||
// ── Usage queries ──
|
||||
|
||||
export function getUsageByKey({ since, until } = {}) {
|
||||
const d = getDb();
|
||||
let where = "WHERE 1=1";
|
||||
const params = [];
|
||||
if (since) { where += " AND created_at >= ?"; params.push(since); }
|
||||
if (until) { where += " AND created_at <= ?"; params.push(until); }
|
||||
|
||||
return d.prepare(`
|
||||
SELECT
|
||||
key_name,
|
||||
COUNT(*) as requests,
|
||||
SUM(CASE WHEN success = 1 THEN 1 ELSE 0 END) as successes,
|
||||
SUM(CASE WHEN success = 0 THEN 1 ELSE 0 END) as errors,
|
||||
SUM(prompt_chars) as total_prompt_chars,
|
||||
SUM(response_chars) as total_response_chars,
|
||||
SUM(elapsed_ms) as total_elapsed_ms,
|
||||
AVG(elapsed_ms) as avg_elapsed_ms,
|
||||
MIN(created_at) as first_request,
|
||||
MAX(created_at) as last_request
|
||||
FROM usage_log
|
||||
${where}
|
||||
GROUP BY key_name
|
||||
ORDER BY requests DESC
|
||||
`).all(...params);
|
||||
}
|
||||
|
||||
export function getUsageTimeline({ keyName, hours = 24 } = {}) {
|
||||
const d = getDb();
|
||||
const since = new Date(Date.now() - hours * 3600000).toISOString();
|
||||
let where = "WHERE created_at >= ?";
|
||||
const params = [since];
|
||||
if (keyName) { where += " AND key_name = ?"; params.push(keyName); }
|
||||
|
||||
return d.prepare(`
|
||||
SELECT
|
||||
strftime('%Y-%m-%dT%H:00:00', created_at) as hour,
|
||||
COUNT(*) as requests,
|
||||
SUM(prompt_chars) as prompt_chars,
|
||||
SUM(response_chars) as response_chars,
|
||||
AVG(elapsed_ms) as avg_elapsed_ms
|
||||
FROM usage_log
|
||||
${where}
|
||||
GROUP BY hour
|
||||
ORDER BY hour
|
||||
`).all(...params);
|
||||
}
|
||||
|
||||
export function getRecentUsage(limit = 50) {
|
||||
const d = getDb();
|
||||
return d.prepare(`
|
||||
SELECT key_name, model, prompt_chars, response_chars, elapsed_ms, success, created_at
|
||||
FROM usage_log
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
}
|
||||
|
||||
// ── SQLite datetime helper ──
|
||||
// SQLite datetime('now') stores as 'YYYY-MM-DD HH:MM:SS' (no T, no Z).
|
||||
// JavaScript .toISOString() produces 'YYYY-MM-DDTHH:MM:SS.sssZ'.
|
||||
// String comparison between the two breaks for same-day ranges (T > space).
|
||||
// This helper formats Date to match SQLite's format for correct comparisons.
|
||||
function sqliteDatetime(date) {
|
||||
return date.toISOString().replace("T", " ").replace(/\.\d{3}Z$/, "");
|
||||
}
|
||||
|
||||
// ── Quota management ──
|
||||
|
||||
// Returns { period, limit, used, resetsIn } if a quota is exceeded, null otherwise.
|
||||
// Anonymous/admin callers (keyId === null) are never subject to quotas.
|
||||
export function checkQuota(keyId, _keyName) {
|
||||
if (keyId === null || keyId === undefined) return null;
|
||||
|
||||
const d = getDb();
|
||||
const keyRow = d.prepare(
|
||||
"SELECT quota_daily, quota_weekly, quota_monthly FROM api_keys WHERE id = ? AND revoked = 0"
|
||||
).get(keyId);
|
||||
if (!keyRow) return null;
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// UTC period boundaries (SQLite-compatible format)
|
||||
const startOfToday = sqliteDatetime(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())));
|
||||
const sevenDaysAgo = sqliteDatetime(new Date(Date.now() - 7 * 86400000));
|
||||
const thirtyDaysAgo = sqliteDatetime(new Date(Date.now() - 30 * 86400000));
|
||||
|
||||
// Next reset times for human display
|
||||
const tomorrowUTC = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() + 1));
|
||||
function msToHuman(ms) {
|
||||
if (ms <= 0) return "now";
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
if (h >= 24) { const d = Math.floor(h / 24); return `${d}d ${h % 24}h`; }
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
// Single query for all periods (widest window = monthly)
|
||||
const row = d.prepare(`
|
||||
SELECT
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as daily_cnt,
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as weekly_cnt,
|
||||
COUNT(*) as monthly_cnt
|
||||
FROM usage_log
|
||||
WHERE key_id = ? AND success = 1 AND created_at >= ?
|
||||
`).get(startOfToday, sevenDaysAgo, keyId, thirtyDaysAgo);
|
||||
|
||||
const checks = [
|
||||
{ period: "daily", limit: keyRow.quota_daily, used: row?.daily_cnt ?? 0, resetsIn: msToHuman(tomorrowUTC - now) },
|
||||
{ period: "weekly", limit: keyRow.quota_weekly, used: row?.weekly_cnt ?? 0, resetsIn: "rolling 7-day window" },
|
||||
{ period: "monthly", limit: keyRow.quota_monthly, used: row?.monthly_cnt ?? 0, resetsIn: "rolling 30-day window" },
|
||||
];
|
||||
|
||||
for (const { period, limit, used, resetsIn } of checks) {
|
||||
if (limit === null || limit === undefined) continue;
|
||||
if (used >= limit) {
|
||||
return { period, limit, used, resetsIn };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Set quota for a key. Only updates fields explicitly present in the input object.
|
||||
// Pass null to clear a specific limit. Omit a field to leave it unchanged.
|
||||
export function updateKeyQuota(idOrName, updates = {}) {
|
||||
const d = getDb();
|
||||
const setClauses = [];
|
||||
const params = [];
|
||||
if ("daily" in updates) { setClauses.push("quota_daily = ?"); params.push(updates.daily ?? null); }
|
||||
if ("weekly" in updates) { setClauses.push("quota_weekly = ?"); params.push(updates.weekly ?? null); }
|
||||
if ("monthly" in updates){ setClauses.push("quota_monthly = ?");params.push(updates.monthly ?? null); }
|
||||
if (setClauses.length === 0) return false;
|
||||
params.push(idOrName, idOrName);
|
||||
const result = d.prepare(
|
||||
`UPDATE api_keys SET ${setClauses.join(", ")} WHERE id = ? OR name = ?`
|
||||
).run(...params);
|
||||
return result.changes > 0;
|
||||
}
|
||||
|
||||
// Returns { daily: { limit, used }, weekly: { limit, used }, monthly: { limit, used } }
|
||||
export function getKeyQuota(keyId) {
|
||||
const d = getDb();
|
||||
const keyRow = d.prepare(
|
||||
"SELECT quota_daily, quota_weekly, quota_monthly FROM api_keys WHERE id = ?"
|
||||
).get(keyId);
|
||||
if (!keyRow) return null;
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = sqliteDatetime(new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())));
|
||||
const sevenDaysAgo = sqliteDatetime(new Date(Date.now() - 7 * 86400000));
|
||||
const thirtyDaysAgo = sqliteDatetime(new Date(Date.now() - 30 * 86400000));
|
||||
|
||||
const row = d.prepare(`
|
||||
SELECT
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as daily_cnt,
|
||||
SUM(CASE WHEN created_at >= ? THEN 1 ELSE 0 END) as weekly_cnt,
|
||||
COUNT(*) as monthly_cnt
|
||||
FROM usage_log
|
||||
WHERE key_id = ? AND success = 1 AND created_at >= ?
|
||||
`).get(startOfToday, sevenDaysAgo, keyId, thirtyDaysAgo);
|
||||
|
||||
return {
|
||||
daily: { limit: keyRow.quota_daily ?? null, used: row?.daily_cnt ?? 0 },
|
||||
weekly: { limit: keyRow.quota_weekly ?? null, used: row?.weekly_cnt ?? 0 },
|
||||
monthly: { limit: keyRow.quota_monthly ?? null, used: row?.monthly_cnt ?? 0 },
|
||||
};
|
||||
}
|
||||
|
||||
// ── Response cache ──
|
||||
|
||||
// Generate a cache key from model + messages + request params that affect output
|
||||
export function cacheHash(model, messages, opts = {}) {
|
||||
const h = createHash("sha256");
|
||||
h.update(model);
|
||||
if (opts.temperature != null) h.update(`t:${opts.temperature}`);
|
||||
if (opts.max_tokens != null) h.update(`mt:${opts.max_tokens}`);
|
||||
if (opts.top_p != null) h.update(`tp:${opts.top_p}`);
|
||||
for (const m of messages) {
|
||||
h.update(m.role || "");
|
||||
h.update(typeof m.content === "string" ? m.content : JSON.stringify(m.content));
|
||||
}
|
||||
return h.digest("hex");
|
||||
}
|
||||
|
||||
// Look up a cached response. Returns { response, hits } or null.
|
||||
// Also updates last_hit_at and increments hits counter on hit.
|
||||
export function getCachedResponse(hash, ttlMs) {
|
||||
const d = getDb();
|
||||
const cutoff = sqliteDatetime(new Date(Date.now() - ttlMs));
|
||||
const row = d.prepare(
|
||||
"SELECT id, response, hits FROM response_cache WHERE hash = ? AND created_at >= ?"
|
||||
).get(hash, cutoff);
|
||||
if (!row) return null;
|
||||
// Update hit stats
|
||||
d.prepare("UPDATE response_cache SET hits = hits + 1, last_hit_at = datetime('now') WHERE id = ?").run(row.id);
|
||||
return { response: row.response, hits: row.hits + 1 };
|
||||
}
|
||||
|
||||
// Store a response in the cache
|
||||
export function setCachedResponse(hash, model, response) {
|
||||
const d = getDb();
|
||||
// Upsert: if hash already exists (race condition), just update
|
||||
d.prepare(`
|
||||
INSERT INTO response_cache (hash, model, response) VALUES (?, ?, ?)
|
||||
ON CONFLICT(hash) DO UPDATE SET response = excluded.response, created_at = datetime('now'), hits = 0
|
||||
`).run(hash, model, response);
|
||||
}
|
||||
|
||||
// Clear all cached responses, or expired ones only
|
||||
export function clearCache(ttlMs = null) {
|
||||
const d = getDb();
|
||||
if (ttlMs === null) {
|
||||
const result = d.prepare("DELETE FROM response_cache").run();
|
||||
return result.changes;
|
||||
}
|
||||
const cutoff = sqliteDatetime(new Date(Date.now() - ttlMs));
|
||||
const result = d.prepare("DELETE FROM response_cache WHERE created_at < ?").run(cutoff);
|
||||
return result.changes;
|
||||
}
|
||||
|
||||
// Get cache statistics
|
||||
export function getCacheStats() {
|
||||
const d = getDb();
|
||||
const total = d.prepare("SELECT COUNT(*) as cnt FROM response_cache").get()?.cnt ?? 0;
|
||||
const totalHits = d.prepare("SELECT SUM(hits) as total FROM response_cache").get()?.total ?? 0;
|
||||
const sizeBytes = d.prepare("SELECT SUM(LENGTH(response)) as size FROM response_cache").get()?.size ?? 0;
|
||||
return { entries: total, totalHits, sizeBytes };
|
||||
}
|
||||
|
||||
// Find a key by id or name (returns { id, name } or null)
|
||||
export function findKey(idOrName) {
|
||||
const d = getDb();
|
||||
return d.prepare("SELECT id, name FROM api_keys WHERE id = ? OR name = ?").get(idOrName, idOrName) || null;
|
||||
}
|
||||
|
||||
export function closeDb() {
|
||||
if (db) { db.close(); db = null; }
|
||||
}
|
||||
@@ -8,6 +8,23 @@ set -euo pipefail
|
||||
|
||||
PROXY="http://127.0.0.1:3456"
|
||||
|
||||
# Auth header for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||
_AUTH_HEADER=""
|
||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
||||
fi
|
||||
|
||||
# Wrapper: curl with optional auth
|
||||
_curl() {
|
||||
if [[ -n "$_AUTH_HEADER" ]]; then
|
||||
eval curl "$_AUTH_HEADER" "$@"
|
||||
else
|
||||
curl "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||
|
||||
_bar() {
|
||||
@@ -29,11 +46,36 @@ Displays current session utilization, weekly limits, extra usage status,
|
||||
and proxy request statistics. Data is fetched from the Anthropic API
|
||||
via a minimal probe call (cached for 5 minutes).
|
||||
|
||||
Usage: ocp usage
|
||||
Usage: ocp usage [--by-key]
|
||||
|
||||
Options:
|
||||
--by-key Show per-key usage statistics
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_usage() {
|
||||
if [[ "${1:-}" == "--by-key" ]]; then
|
||||
local data
|
||||
data=$(_curl -sf --max-time 15 "$PROXY/api/usage" 2>&1) || { echo "Error: proxy unreachable or usage API not available"; exit 1; }
|
||||
echo "$data" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
by_key = d.get('byKey', [])
|
||||
if not by_key:
|
||||
print('No usage data yet.')
|
||||
else:
|
||||
print('Usage by Key')
|
||||
print('─────────────────────────────────────────────────────────────────')
|
||||
hdr = f' {\"Key\":<20} {\"Reqs\":>5} {\"OK\":>4} {\"Err\":>4} {\"Avg Time\":>9} {\"Last Request\":<20}'
|
||||
print(hdr)
|
||||
print(' ' + '─' * (len(hdr) - 2))
|
||||
for k in by_key:
|
||||
avg_t = f'{k[\"avg_elapsed_ms\"]/1000:.1f}s' if k['avg_elapsed_ms'] else '-'
|
||||
print(f' {k[\"key_name\"]:<20} {k[\"requests\"]:>5} {k[\"successes\"]:>4} {k[\"errors\"]:>4} {avg_t:>9} {k[\"last_request\"]:<20}')
|
||||
"
|
||||
return
|
||||
fi
|
||||
|
||||
local data
|
||||
data=$(curl -sf --max-time 15 "$PROXY/usage" 2>&1) || { echo "Error: proxy unreachable"; exit 1; }
|
||||
|
||||
@@ -213,6 +255,318 @@ cmd_clear() {
|
||||
echo "Cleared $count sessions."
|
||||
}
|
||||
|
||||
# ── keys ────────────────────────────────────────────────────────────────
|
||||
cmd_keys_help() {
|
||||
cat <<'EOF'
|
||||
ocp keys — Manage API keys (multi-key auth mode)
|
||||
|
||||
Usage:
|
||||
ocp keys List all keys
|
||||
ocp keys add <name> Create a new key
|
||||
ocp keys revoke <name|id> Revoke a key
|
||||
|
||||
Examples:
|
||||
ocp keys add wife-laptop
|
||||
ocp keys add son-ipad
|
||||
ocp keys revoke wife-laptop
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_keys() {
|
||||
case "${1:-}" in
|
||||
add)
|
||||
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys add <name>"; return 1; fi
|
||||
local result
|
||||
result=$(_curl -sf --max-time 5 -X POST "$PROXY/api/keys" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\": \"$2\"}" 2>&1) || { echo "Error: proxy unreachable or unauthorized"; exit 1; }
|
||||
echo "$result" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
if 'key' in d:
|
||||
print(f'✓ Key created for \"{d[\"name\"]}\"')
|
||||
print(f'')
|
||||
print(f' API Key: {d[\"key\"]}')
|
||||
print(f'')
|
||||
print(f' Copy this key now — you won\\'t see it again.')
|
||||
print(f' Configure in IDE: OPENAI_API_KEY={d[\"key\"]}')
|
||||
else:
|
||||
print(f'✗ {d.get(\"error\", \"Unknown error\")}')
|
||||
"
|
||||
;;
|
||||
revoke)
|
||||
if [[ -z "${2:-}" ]]; then echo "Usage: ocp keys revoke <name|id>"; return 1; fi
|
||||
_curl -sf --max-time 5 -X DELETE "$PROXY/api/keys/$2" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
if d.get('revoked'):
|
||||
print(f'✓ Key \"{d[\"idOrName\"]}\" revoked.')
|
||||
else:
|
||||
print(f'✗ Key not found or already revoked.')
|
||||
"
|
||||
;;
|
||||
--help|-h)
|
||||
cmd_keys_help
|
||||
;;
|
||||
"")
|
||||
_curl -sf --max-time 5 "$PROXY/api/keys" | python3 -c "
|
||||
import sys, json
|
||||
d = json.loads(sys.stdin.read())
|
||||
keys = d.get('keys', [])
|
||||
if not keys:
|
||||
print('No API keys configured.')
|
||||
print('Create one: ocp keys add <name>')
|
||||
else:
|
||||
print('API Keys')
|
||||
print('─────────────────────────────────────────────────')
|
||||
for k in keys:
|
||||
status = '✗ revoked' if k['revoked'] else '✓ active'
|
||||
print(f' {k[\"name\"]:<20} {k[\"keyPreview\"]:<20} {status} {k[\"created_at\"]}')
|
||||
" 2>/dev/null || { echo "Error: proxy unreachable or key management not available"; exit 1; }
|
||||
;;
|
||||
*)
|
||||
echo "Unknown subcommand: $1"; cmd_keys_help; return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# ── connect ─────────────────────────────────────────────────────────────
|
||||
cmd_connect_help() {
|
||||
cat <<'EOF'
|
||||
ocp connect — Connect this machine to a remote OCP as a LAN client
|
||||
|
||||
Configures OPENAI_BASE_URL (and optionally OPENAI_API_KEY) in your shell
|
||||
rc file so tools like Claude Code point to a remote OCP instance.
|
||||
|
||||
Usage:
|
||||
ocp connect <host-ip> [--port PORT] [--key API_KEY]
|
||||
|
||||
Arguments:
|
||||
host-ip IP address of the machine running OCP
|
||||
--port PORT Port OCP listens on (default: 3456)
|
||||
--key API_KEY API key to use (prompted if remote requires auth)
|
||||
|
||||
Examples:
|
||||
ocp connect 192.168.1.10
|
||||
ocp connect 192.168.1.10 --port 8080
|
||||
ocp connect 192.168.1.10 --key sk-abc123
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_connect() {
|
||||
local host="" port=3456 key=""
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
||||
--key) key="${2:?'--key requires a value'}"; shift 2 ;;
|
||||
--help|-h) cmd_connect_help; return 0 ;;
|
||||
-*) echo "Unknown option: $1"; cmd_connect_help; return 1 ;;
|
||||
*) host="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$host" ]]; then
|
||||
echo "Error: host IP is required."
|
||||
echo ""
|
||||
cmd_connect_help
|
||||
return 1
|
||||
fi
|
||||
|
||||
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||
echo "Error: invalid host '$host'"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local base_url="http://$host:$port"
|
||||
|
||||
echo "OCP Connect"
|
||||
echo "─────────────────────────────────────"
|
||||
echo " Remote: $base_url"
|
||||
echo ""
|
||||
|
||||
# Step 1: Test connectivity via /health
|
||||
echo " Checking connectivity..."
|
||||
local health_json
|
||||
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
||||
echo " ✗ Cannot reach $base_url/health"
|
||||
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||
return 1
|
||||
}
|
||||
echo " ✓ Connected"
|
||||
echo ""
|
||||
|
||||
# Step 2: Show remote info
|
||||
local remote_version auth_mode
|
||||
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
||||
|
||||
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
||||
echo ""
|
||||
|
||||
# Step 3: Determine if key is needed
|
||||
local needs_key=0
|
||||
if [[ "$auth_mode" != "none" ]]; then
|
||||
needs_key=1
|
||||
fi
|
||||
|
||||
if [[ $needs_key -eq 1 && -z "$key" ]]; then
|
||||
echo " Remote requires authentication."
|
||||
echo " Ask the admin to run: ocp keys add <name>"
|
||||
printf " Enter API key (or press Enter to skip): "
|
||||
read -rs key </dev/tty
|
||||
echo
|
||||
if [[ -z "$key" ]]; then
|
||||
echo ""
|
||||
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4: Test API access via /v1/models
|
||||
echo " Testing API access..."
|
||||
local models_out models_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
models_out=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
else
|
||||
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
fi
|
||||
|
||||
if [[ $models_ok -eq 0 ]]; then
|
||||
echo " ✗ API access failed — key may be invalid or revoked."
|
||||
return 1
|
||||
fi
|
||||
local model_count
|
||||
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
||||
echo " ✓ API accessible ($model_count models available)"
|
||||
echo ""
|
||||
|
||||
# Step 5: Detect shell rc file
|
||||
local rc_file
|
||||
if [[ "${SHELL:-}" == */zsh ]]; then
|
||||
rc_file="$HOME/.zshrc"
|
||||
else
|
||||
rc_file="$HOME/.bashrc"
|
||||
fi
|
||||
|
||||
# Step 6: Remove any previously written OCP LAN lines
|
||||
if [[ -f "$rc_file" ]]; then
|
||||
local tmp_rc
|
||||
tmp_rc=$(mktemp)
|
||||
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
lines = open(src).readlines()
|
||||
out = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
s = line.rstrip('\n')
|
||||
if s == '# OCP LAN (added by ocp connect)':
|
||||
skip = True
|
||||
continue
|
||||
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
||||
continue
|
||||
skip = False
|
||||
out.append(line)
|
||||
open(dst, 'w').writelines(out)
|
||||
PYEOF
|
||||
then
|
||||
cp "$tmp_rc" "$rc_file"
|
||||
fi
|
||||
rm -f "$tmp_rc"
|
||||
fi
|
||||
|
||||
# Step 7: Append new config
|
||||
{
|
||||
echo ""
|
||||
echo "# OCP LAN (added by ocp connect)"
|
||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} >> "$rc_file"
|
||||
|
||||
echo " Written to $rc_file:"
|
||||
echo " OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo " OPENAI_API_KEY=${key:0:8}..."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 8: Quick smoke test — send a minimal chat completion
|
||||
echo " Running smoke test..."
|
||||
local chat_payload='{"model":"claude-haiku-4-5-20251001","messages":[{"role":"user","content":"Reply with OK only."}],"max_tokens":10}'
|
||||
local chat_out chat_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
else
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
fi
|
||||
|
||||
if [[ $chat_ok -eq 1 ]]; then
|
||||
local reply
|
||||
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
||||
echo " ✓ Smoke test passed: $reply"
|
||||
else
|
||||
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
||||
echo " The env vars have still been written. Check the remote OCP logs."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Done. Reload your shell to apply:"
|
||||
echo " source $rc_file"
|
||||
}
|
||||
|
||||
# ── lan ─────────────────────────────────────────────────────────────────
|
||||
cmd_lan_help() {
|
||||
cat <<'EOF'
|
||||
ocp lan — Quick LAN mode setup guide
|
||||
|
||||
Shows current network configuration and connection instructions
|
||||
for other devices on the same network.
|
||||
|
||||
Usage: ocp lan
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_lan() {
|
||||
local ip
|
||||
ip=$(ipconfig getifaddr en0 2>/dev/null || ipconfig getifaddr en1 2>/dev/null || hostname -I 2>/dev/null | awk '{print $1}' || echo "unknown")
|
||||
local port=3456
|
||||
|
||||
echo "OCP LAN Setup"
|
||||
echo "─────────────────────────────────────"
|
||||
echo ""
|
||||
echo " Your IP: $ip"
|
||||
echo " Port: $port"
|
||||
echo ""
|
||||
echo " For IDE users, set:"
|
||||
echo " OPENAI_BASE_URL=http://$ip:$port/v1"
|
||||
echo " OPENAI_API_KEY=<your-key> (if auth enabled)"
|
||||
echo ""
|
||||
echo " Dashboard: http://$ip:$port/dashboard"
|
||||
echo ""
|
||||
|
||||
if curl -sf --max-time 2 "http://$ip:$port/health" > /dev/null 2>&1; then
|
||||
echo " Status: ✓ LAN-accessible"
|
||||
else
|
||||
echo " Status: ✗ Not LAN-accessible (bound to localhost only)"
|
||||
echo ""
|
||||
echo " To enable LAN mode, set env var and restart:"
|
||||
echo " CLAUDE_BIND=0.0.0.0"
|
||||
echo " ocp restart"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── restart ──────────────────────────────────────────────────────────────
|
||||
cmd_restart_help() {
|
||||
cat <<'EOF'
|
||||
@@ -231,12 +585,27 @@ cmd_restart() {
|
||||
openclaw gateway restart 2>&1
|
||||
else
|
||||
echo "Restarting proxy..."
|
||||
launchctl kickstart -k gui/501/ai.openclaw.proxy 2>/dev/null || {
|
||||
echo "launchctl failed, trying kill + restart..."
|
||||
pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
|
||||
# Try current service name, then legacy, then manual restart
|
||||
local uid
|
||||
uid=$(id -u)
|
||||
if launchctl kickstart -k "gui/$uid/dev.ocp.proxy" 2>/dev/null; then
|
||||
true
|
||||
elif launchctl kickstart -k "gui/$uid/ai.openclaw.proxy" 2>/dev/null; then
|
||||
true
|
||||
elif systemctl --user restart ocp-proxy 2>/dev/null; then
|
||||
true
|
||||
elif systemctl --user restart openclaw-proxy 2>/dev/null; then
|
||||
true
|
||||
else
|
||||
echo "Service restart failed, trying kill + restart..."
|
||||
pkill -f "server.mjs.*CLAUDE_PROXY" 2>/dev/null || pkill -f "claude-proxy/server.mjs" 2>/dev/null || true
|
||||
sleep 1
|
||||
cd "$HOME/.openclaw/projects/claude-proxy" && nohup node server.mjs >> "$HOME/.openclaw/logs/proxy.log" 2>&1 &
|
||||
}
|
||||
local self_r script_dir
|
||||
self_r="${BASH_SOURCE[0]}"
|
||||
while [[ -L "$self_r" ]]; do self_r="$(readlink "$self_r")"; done
|
||||
script_dir="$(cd "$(dirname "$self_r")" && pwd)"
|
||||
nohup node "$script_dir/server.mjs" >> "$HOME/.ocp/logs/proxy.log" 2>&1 &
|
||||
fi
|
||||
sleep 3
|
||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||
echo "✓ Proxy restarted successfully."
|
||||
@@ -323,6 +692,102 @@ for e in d.get('errors', []):
|
||||
fi
|
||||
}
|
||||
|
||||
# ── update ──────────────────────────────────────────────────────────────
|
||||
cmd_update_help() {
|
||||
cat <<'EOF'
|
||||
ocp update — Update OCP to the latest version
|
||||
|
||||
Pulls the latest code from GitHub, restarts the proxy service,
|
||||
and optionally syncs the plugin to the OpenClaw extensions directory.
|
||||
|
||||
Usage:
|
||||
ocp update Pull latest and restart
|
||||
ocp update --check Check for updates without applying
|
||||
EOF
|
||||
}
|
||||
|
||||
cmd_update() {
|
||||
local script_dir self
|
||||
self="${BASH_SOURCE[0]}"
|
||||
# Resolve symlinks (e.g. ~/.local/bin/ocp → real location)
|
||||
while [[ -L "$self" ]]; do self="$(readlink "$self")"; done
|
||||
script_dir="$(cd "$(dirname "$self")" && pwd)"
|
||||
|
||||
# Check-only mode
|
||||
if [[ "${1:-}" == "--check" ]]; then
|
||||
cd "$script_dir"
|
||||
git fetch origin main --quiet 2>/dev/null || true
|
||||
local local_ver remote_ver
|
||||
local_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
remote_ver=$(git show origin/main:package.json 2>/dev/null | python3 -c "import sys,json; print(json.load(sys.stdin)['version'])" 2>/dev/null || echo "?")
|
||||
local behind
|
||||
behind=$(git rev-list HEAD..origin/main --count 2>/dev/null || echo "?")
|
||||
echo "OCP Update Check"
|
||||
echo "─────────────────────────────────────"
|
||||
echo " Current: v$local_ver"
|
||||
echo " Latest: v$remote_ver"
|
||||
if [[ "$behind" == "0" ]]; then
|
||||
echo " Status: ✓ Up to date"
|
||||
else
|
||||
echo " Status: $behind commit(s) behind"
|
||||
echo ""
|
||||
echo " Run 'ocp update' to apply."
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
echo "Updating OCP..."
|
||||
echo ""
|
||||
|
||||
# 1. Pull latest
|
||||
cd "$script_dir"
|
||||
local old_ver
|
||||
old_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
|
||||
echo " Pulling latest from GitHub..."
|
||||
if ! git pull origin main --ff-only 2>&1 | sed 's/^/ /'; then
|
||||
echo " ✗ Git pull failed. Resolve conflicts manually in: $script_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
local new_ver
|
||||
new_ver=$(python3 -c "import json; print(json.load(open('package.json'))['version'])" 2>/dev/null || echo "?")
|
||||
|
||||
if [[ "$old_ver" == "$new_ver" ]]; then
|
||||
echo " ✓ Already at latest (v$new_ver)"
|
||||
else
|
||||
echo " ✓ Updated v$old_ver → v$new_ver"
|
||||
fi
|
||||
|
||||
# 2. Sync plugin to extensions dir
|
||||
local ext_dir="$HOME/.openclaw/extensions/ocp"
|
||||
if [[ -d "$ext_dir" && -d "$script_dir/ocp-plugin" ]]; then
|
||||
echo ""
|
||||
echo " Syncing OCP plugin..."
|
||||
cp "$script_dir/ocp-plugin/index.js" "$ext_dir/index.js" 2>/dev/null
|
||||
cp "$script_dir/ocp-plugin/package.json" "$ext_dir/package.json" 2>/dev/null
|
||||
cp "$script_dir/ocp-plugin/openclaw.plugin.json" "$ext_dir/openclaw.plugin.json" 2>/dev/null
|
||||
echo " ✓ Plugin synced to $ext_dir"
|
||||
fi
|
||||
|
||||
# 3. Restart proxy
|
||||
echo ""
|
||||
echo " Restarting proxy..."
|
||||
cmd_restart > /dev/null 2>&1
|
||||
sleep 2
|
||||
|
||||
if curl -sf --max-time 5 "$PROXY/health" > /dev/null 2>&1; then
|
||||
local running_ver
|
||||
running_ver=$(curl -sf --max-time 5 "$PROXY/health" | python3 -c "import sys,json; print(json.loads(sys.stdin.read())['version'])" 2>/dev/null || echo "?")
|
||||
echo " ✓ Proxy running (v$running_ver)"
|
||||
else
|
||||
echo " ⚠ Proxy not responding — check: ocp health"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Done."
|
||||
}
|
||||
|
||||
# ── help ─────────────────────────────────────────────────────────────────
|
||||
cmd_help() {
|
||||
cat <<'EOF'
|
||||
@@ -331,7 +796,7 @@ ocp — OpenClaw Proxy CLI
|
||||
Usage: ocp <command> [args]
|
||||
|
||||
Commands:
|
||||
usage Plan usage limits
|
||||
usage Plan usage limits (--by-key for per-key stats)
|
||||
status Quick overview (usage + health)
|
||||
health Proxy diagnostics
|
||||
settings View or update tunable settings
|
||||
@@ -339,8 +804,13 @@ Commands:
|
||||
models Available models
|
||||
sessions Active sessions
|
||||
clear Clear all sessions
|
||||
keys Manage API keys (add/list/revoke)
|
||||
lan LAN mode setup guide
|
||||
connect <ip> Connect to a remote OCP (sets env vars in rc file)
|
||||
restart Restart proxy
|
||||
restart gateway Restart gateway
|
||||
update Update OCP to latest version
|
||||
update --check Check for updates
|
||||
|
||||
Run 'ocp <command> --help' for details on a specific command.
|
||||
EOF
|
||||
@@ -371,7 +841,7 @@ for arg in "$@"; do
|
||||
done
|
||||
|
||||
case "$subcmd" in
|
||||
usage) cmd_usage ;;
|
||||
usage) cmd_usage "${1:-}" ;;
|
||||
status) cmd_status ;;
|
||||
health) cmd_health ;;
|
||||
settings) cmd_settings "${1:-}" "${2:-}" ;;
|
||||
@@ -379,6 +849,10 @@ case "$subcmd" in
|
||||
models) cmd_models ;;
|
||||
sessions) cmd_sessions ;;
|
||||
clear) cmd_clear ;;
|
||||
keys) cmd_keys "${1:-}" "${2:-}" ;;
|
||||
lan) cmd_lan ;;
|
||||
connect) cmd_connect "$@" ;;
|
||||
restart) cmd_restart "${1:-}" ;;
|
||||
update) cmd_update "${1:-}" ;;
|
||||
*) echo "Unknown command: $subcmd"; echo ""; cmd_help; exit 1 ;;
|
||||
esac
|
||||
|
||||
Executable
+711
@@ -0,0 +1,711 @@
|
||||
#!/usr/bin/env bash
|
||||
# ocp-connect — Lightweight client script to connect to a remote OCP instance
|
||||
# No dependencies beyond curl and python3 (available on most Linux/Mac systems)
|
||||
#
|
||||
# Install:
|
||||
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect -o ocp-connect
|
||||
# chmod +x ocp-connect
|
||||
#
|
||||
# Or run directly:
|
||||
# curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <host-ip> --key <key>
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
OCP_CONNECT_VERSION="1.3.0"
|
||||
|
||||
show_version() {
|
||||
echo "ocp-connect $OCP_CONNECT_VERSION"
|
||||
}
|
||||
|
||||
show_help() {
|
||||
cat <<'EOF'
|
||||
ocp-connect — Connect this machine to a remote OCP (Open Claude Proxy)
|
||||
|
||||
Configures OPENAI_BASE_URL and OPENAI_API_KEY in your shell rc file
|
||||
so tools like Claude Code, Cline, Aider, etc. point to the remote OCP.
|
||||
|
||||
Usage:
|
||||
ocp-connect <host-ip> [options]
|
||||
|
||||
Options:
|
||||
--port PORT Port OCP listens on (default: 3456)
|
||||
--key API_KEY API key (prompted interactively if remote requires auth)
|
||||
--version Print version and exit
|
||||
--help, -h Show this help
|
||||
|
||||
Examples:
|
||||
ocp-connect 192.168.1.10
|
||||
ocp-connect 192.168.1.10 --port 8080
|
||||
ocp-connect 192.168.1.10 --key ocp_abc123
|
||||
|
||||
What it does:
|
||||
1. Tests connectivity to the remote OCP
|
||||
2. Verifies your API key (if auth is enabled)
|
||||
3. Writes OPENAI_BASE_URL and OPENAI_API_KEY to ~/.bashrc or ~/.zshrc
|
||||
4. Sets system-level env vars (launchctl on macOS, systemd on Linux)
|
||||
5. Configures OpenClaw automatically; prints setup hints for other IDEs
|
||||
(Cline, Continue.dev, Cursor — manual configuration required)
|
||||
6. Runs a smoke test to confirm everything works
|
||||
|
||||
After running, reload your shell: source ~/.bashrc (or ~/.zshrc)
|
||||
EOF
|
||||
}
|
||||
|
||||
configure_ides() {
|
||||
local base_url="$1" key="$2" models_out="$3"
|
||||
|
||||
# --- OpenClaw ---
|
||||
local oc_config="$HOME/.openclaw/openclaw.json"
|
||||
local oc_found=false
|
||||
if command -v openclaw &>/dev/null || [[ -f "$oc_config" ]]; then
|
||||
oc_found=true
|
||||
fi
|
||||
|
||||
if $oc_found; then
|
||||
echo " IDE Configuration"
|
||||
echo " ─────────────────────────────────────"
|
||||
echo " Detected: OpenClaw ($oc_config)"
|
||||
echo ""
|
||||
printf " Configure OpenClaw to use this OCP? [Y/n] "
|
||||
local oc_answer
|
||||
{ read -r oc_answer </dev/tty; } 2>/dev/null || oc_answer="y"
|
||||
oc_answer="${oc_answer:-y}"
|
||||
|
||||
if [[ "$oc_answer" =~ ^[Yy]$ ]]; then
|
||||
# Ask for provider name
|
||||
printf " Provider name (models show as <name>/model-id) [ocp]: "
|
||||
local provider_name
|
||||
{ read -r provider_name </dev/tty; } 2>/dev/null || provider_name=""
|
||||
provider_name="${provider_name:-ocp}"
|
||||
# Sanitize: only allow alphanumeric, dash, underscore
|
||||
provider_name=$(echo "$provider_name" | tr -cd 'a-zA-Z0-9_-')
|
||||
[[ -z "$provider_name" ]] && provider_name="ocp"
|
||||
|
||||
# Ask for priority
|
||||
echo ""
|
||||
echo " How should OCP models be configured?"
|
||||
echo " 1) Primary — use OCP by default, keep existing models as backup"
|
||||
echo " 2) Backup — keep current primary, add OCP as additional option"
|
||||
echo ""
|
||||
printf " Choice [1]: "
|
||||
local priority_choice
|
||||
{ read -r priority_choice </dev/tty; } 2>/dev/null || priority_choice="1"
|
||||
priority_choice="${priority_choice:-1}"
|
||||
|
||||
echo ""
|
||||
echo " Writing OpenClaw config..."
|
||||
|
||||
# Use python3 to safely manipulate JSON
|
||||
local py_ok=0
|
||||
python3 - "$oc_config" "$base_url" "$key" "$provider_name" "$priority_choice" "$models_out" <<'PYEOF' && py_ok=1
|
||||
import sys, json, os
|
||||
|
||||
config_path = sys.argv[1]
|
||||
base_url = sys.argv[2]
|
||||
api_key = sys.argv[3]
|
||||
provider_name = sys.argv[4]
|
||||
priority = sys.argv[5] # "1" = primary, "2" = backup
|
||||
models_json_str = sys.argv[6]
|
||||
|
||||
# Parse models from OCP /v1/models response
|
||||
try:
|
||||
models_data = json.loads(models_json_str)
|
||||
model_ids = [m["id"] for m in models_data.get("data", [])]
|
||||
except:
|
||||
model_ids = ["claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4"]
|
||||
|
||||
# Build provider entry
|
||||
provider = {
|
||||
"baseUrl": base_url + "/v1",
|
||||
"api": "openai-completions",
|
||||
"authHeader": bool(api_key),
|
||||
"models": []
|
||||
}
|
||||
|
||||
# Model metadata mapping (prefix match for versioned IDs like claude-haiku-4-5-20251001)
|
||||
model_meta = {
|
||||
"claude-opus-4": {"name": "Claude Opus (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-sonnet-4": {"name": "Claude Sonnet (OCP)", "reasoning": True, "maxTokens": 16384},
|
||||
"claude-haiku-4": {"name": "Claude Haiku (OCP)", "reasoning": False, "maxTokens": 8192},
|
||||
}
|
||||
|
||||
def get_model_meta(mid):
|
||||
"""Match model metadata by prefix."""
|
||||
for prefix, meta in sorted(model_meta.items(), key=lambda x: -len(x[0])):
|
||||
if mid.startswith(prefix):
|
||||
return meta
|
||||
return {"name": mid + " (OCP)", "reasoning": False, "maxTokens": 8192}
|
||||
|
||||
for mid in model_ids:
|
||||
meta = get_model_meta(mid)
|
||||
provider["models"].append({
|
||||
"id": mid,
|
||||
"name": meta["name"],
|
||||
"reasoning": meta["reasoning"],
|
||||
"input": ["text"],
|
||||
"cost": {"input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0},
|
||||
"contextWindow": 200000,
|
||||
"maxTokens": meta["maxTokens"]
|
||||
})
|
||||
|
||||
# Load or create config
|
||||
if os.path.exists(config_path):
|
||||
with open(config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
else:
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
config = {}
|
||||
|
||||
# Ensure models.providers exists
|
||||
config.setdefault("models", {})
|
||||
config["models"].setdefault("mode", "merge")
|
||||
config["models"].setdefault("providers", {})
|
||||
|
||||
# Remove any previous OCP provider with the same name
|
||||
config["models"]["providers"][provider_name] = provider
|
||||
|
||||
# Set up auth profile if key is provided
|
||||
if api_key:
|
||||
config.setdefault("auth", {})
|
||||
config["auth"].setdefault("profiles", {})
|
||||
config["auth"]["profiles"][provider_name + ":default"] = {
|
||||
"provider": provider_name,
|
||||
"mode": "api_key"
|
||||
}
|
||||
|
||||
# Configure agent defaults — add model aliases
|
||||
config.setdefault("agents", {})
|
||||
config["agents"].setdefault("defaults", {})
|
||||
config["agents"]["defaults"].setdefault("models", {})
|
||||
|
||||
# Build alias map (prefix match)
|
||||
alias_prefixes = {
|
||||
"claude-opus-4": "Claude Opus",
|
||||
"claude-sonnet-4": "Claude Sonnet",
|
||||
"claude-haiku-4": "Claude Haiku",
|
||||
}
|
||||
|
||||
for mid in model_ids:
|
||||
full_id = provider_name + "/" + mid
|
||||
alias = mid # fallback
|
||||
for prefix, name in sorted(alias_prefixes.items(), key=lambda x: -len(x[0])):
|
||||
if mid.startswith(prefix):
|
||||
alias = name
|
||||
break
|
||||
config["agents"]["defaults"]["models"][full_id] = {"alias": alias}
|
||||
|
||||
# Handle primary/backup
|
||||
if priority == "1":
|
||||
# OCP as primary — pick the best model (prefer sonnet for daily use)
|
||||
primary_model = provider_name + "/claude-sonnet-4-6" if "claude-sonnet-4-6" in model_ids else provider_name + "/" + model_ids[0]
|
||||
config["agents"]["defaults"].setdefault("model", {})
|
||||
config["agents"]["defaults"]["model"]["primary"] = primary_model
|
||||
# Keep existing fallbacks
|
||||
config["agents"]["defaults"]["model"].setdefault("fallbacks", [])
|
||||
|
||||
# If backup (priority == "2"), don't change the primary — just add models to the list
|
||||
|
||||
# Update agent list entries that use old provider name patterns
|
||||
# (only update if agents.list exists and has entries using old OCP-like providers)
|
||||
if "list" in config.get("agents", {}):
|
||||
for agent in config["agents"]["list"]:
|
||||
agent_model = agent.get("model", {})
|
||||
if priority == "1" and agent_model.get("primary", "").startswith("claude-local/"):
|
||||
# Migrate from claude-local to new provider
|
||||
old_model_id = agent_model["primary"].split("/", 1)[1]
|
||||
if old_model_id in model_ids:
|
||||
agent_model["primary"] = provider_name + "/" + old_model_id
|
||||
# Also update subagents if they use claude-local
|
||||
sub = agent.get("subagents", config["agents"]["defaults"].get("subagents", {}))
|
||||
# Don't modify subagents in agent entries — they inherit from defaults
|
||||
|
||||
# Update defaults subagents model if using claude-local
|
||||
if priority == "1":
|
||||
sub = config["agents"]["defaults"].get("subagents", {})
|
||||
if sub.get("model", "").startswith("claude-local/"):
|
||||
old_id = sub["model"].split("/", 1)[1]
|
||||
if old_id in model_ids:
|
||||
sub["model"] = provider_name + "/" + old_id
|
||||
|
||||
with open(config_path, "w") as f:
|
||||
json.dump(config, f, indent=2, ensure_ascii=False)
|
||||
f.write("\n")
|
||||
|
||||
# === B1 fix: seed per-agent auth-profiles.json for OpenClaw multi-agent setups ===
|
||||
# OpenClaw's per-agent auth loader reads <agentDir>/auth-profiles.json (NOT the
|
||||
# root openclaw.json's auth.profiles section). Without a real key in each agent's
|
||||
# agentDir, OpenClaw rejects the lane with "No API key found for provider X".
|
||||
# See https://github.com/dtzp555-max/ocp/issues/12 for the full investigation.
|
||||
_seeded = []
|
||||
_failed = []
|
||||
_skipped_anonymous = []
|
||||
_profile_key = provider_name + ":default"
|
||||
# OpenClaw stores per-agent auth in <openclaw_root>/agents/<id>/agent/ when
|
||||
# the agent has no explicit `agentDir` field — derive that path so the
|
||||
# default `main` agent (which never sets agentDir) is also seeded.
|
||||
_openclaw_root = os.path.dirname(config_path)
|
||||
for _agent in config.get("agents", {}).get("list", []):
|
||||
_agent_dir = _agent.get("agentDir")
|
||||
if not _agent_dir:
|
||||
_agent_id = _agent.get("id")
|
||||
if not _agent_id:
|
||||
continue
|
||||
_agent_dir = os.path.join(_openclaw_root, "agents", _agent_id, "agent")
|
||||
if not api_key:
|
||||
# Anonymous mode is incompatible with OpenClaw per-agent auth (empty key
|
||||
# is dropped by OpenClaw's pi-auth-credentials). Per OCP issue #12 §14
|
||||
# decision: take Path C (require --key for OpenClaw multi-agent setups).
|
||||
_skipped_anonymous.append(_agent_dir)
|
||||
continue
|
||||
_profiles_path = os.path.join(_agent_dir, "auth-profiles.json")
|
||||
try:
|
||||
os.makedirs(_agent_dir, exist_ok=True)
|
||||
except OSError as _e:
|
||||
_failed.append((_profiles_path, "mkdir: " + str(_e)))
|
||||
continue
|
||||
if os.path.exists(_profiles_path):
|
||||
try:
|
||||
with open(_profiles_path) as _f:
|
||||
_ap = json.load(_f)
|
||||
except json.JSONDecodeError:
|
||||
# Corrupted JSON — back up and rebuild from scratch.
|
||||
_bak = _profiles_path + ".bak"
|
||||
try:
|
||||
os.rename(_profiles_path, _bak)
|
||||
print(f" ⚠ {_profiles_path} was corrupt; backed up to {_bak}")
|
||||
except OSError:
|
||||
pass
|
||||
_ap = {"version": 1, "profiles": {}}
|
||||
except OSError as _e:
|
||||
# Read failure (permissions, disk) — skip to AVOID clobbering
|
||||
# the user's existing profile (which may hold other providers' keys).
|
||||
_failed.append((_profiles_path, "read: " + str(_e)))
|
||||
continue
|
||||
else:
|
||||
_ap = {"version": 1, "profiles": {}}
|
||||
_ap.setdefault("version", 1)
|
||||
_ap.setdefault("profiles", {})
|
||||
_ap["profiles"][_profile_key] = {
|
||||
"type": "api_key",
|
||||
"provider": provider_name,
|
||||
"key": api_key
|
||||
}
|
||||
try:
|
||||
with open(_profiles_path, "w") as _f:
|
||||
json.dump(_ap, _f, indent=2, ensure_ascii=False)
|
||||
_f.write("\n")
|
||||
os.chmod(_profiles_path, 0o600)
|
||||
_seeded.append(_profiles_path)
|
||||
except OSError as _e:
|
||||
_failed.append((_profiles_path, "write: " + str(_e)))
|
||||
|
||||
# Report — all three sections are independent (mixed scenarios are surfaced).
|
||||
if _seeded:
|
||||
print(f" ✓ Per-agent auth profile seeded ({len(_seeded)}):")
|
||||
for _p in _seeded:
|
||||
print(f" • {_p}")
|
||||
if _failed:
|
||||
print(f" ⚠ Per-agent auth profile FAILED ({len(_failed)}):")
|
||||
for _p, _err in _failed:
|
||||
print(f" • {_p}: {_err}")
|
||||
print(" OpenClaw will report \"No API key found\" for the failed agents.")
|
||||
print(" Fix the underlying error (permissions / disk) and re-run ocp-connect.")
|
||||
if _skipped_anonymous:
|
||||
print(f" ⚠ OpenClaw multi-agent mode detected ({len(_skipped_anonymous)} agents).")
|
||||
print(" Anonymous mode does not work for OpenClaw per-agent auth.")
|
||||
print(" Re-run with: ocp-connect <host> --key ocp_xxx")
|
||||
PYEOF
|
||||
|
||||
if [[ $py_ok -eq 1 ]]; then
|
||||
echo " ✓ OpenClaw configured"
|
||||
echo " Provider: $provider_name"
|
||||
echo " Models:"
|
||||
# List models from the already-fetched models_out
|
||||
echo "$models_out" | python3 -c "
|
||||
import sys,json
|
||||
d=json.loads(sys.stdin.read())
|
||||
pn='$provider_name'
|
||||
for m in d.get('data',[]):
|
||||
print(' • ' + pn + '/' + m['id'])
|
||||
" 2>/dev/null
|
||||
if [[ "$priority_choice" == "1" ]]; then
|
||||
echo " Priority: PRIMARY (default model)"
|
||||
else
|
||||
echo " Priority: BACKUP (available in model selector)"
|
||||
fi
|
||||
echo ""
|
||||
echo " Restart OpenClaw to apply: openclaw gateway restart"
|
||||
else
|
||||
echo " ⚠ Failed to write OpenClaw config. You can configure it manually."
|
||||
fi
|
||||
else
|
||||
echo " Skipped OpenClaw configuration."
|
||||
fi
|
||||
echo ""
|
||||
fi
|
||||
|
||||
# --- Other IDEs: print manual instructions ---
|
||||
local other_ides_shown=false
|
||||
|
||||
# Collect VS Code extension list once (reused by Cline and Continue.dev checks)
|
||||
local _vscode_exts=""
|
||||
if command -v code &>/dev/null; then
|
||||
_vscode_exts=$(code --list-extensions 2>/dev/null || true)
|
||||
fi
|
||||
if [[ -z "$_vscode_exts" && -d "$HOME/.vscode/extensions" ]]; then
|
||||
_vscode_exts=$(ls "$HOME/.vscode/extensions/" 2>/dev/null || true)
|
||||
fi
|
||||
|
||||
# Extract model IDs from /v1/models response for use in hints
|
||||
local _model_ids
|
||||
_model_ids=$(echo "$models_out" | python3 -c "
|
||||
import sys,json
|
||||
try:
|
||||
d=json.loads(sys.stdin.read())
|
||||
print(', '.join(m['id'] for m in d.get('data', [])))
|
||||
except Exception:
|
||||
print('claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001')
|
||||
" 2>/dev/null || echo "claude-sonnet-4-6, claude-opus-4-6, claude-haiku-4-5-20251001")
|
||||
|
||||
# Key display: truncate if longer than 16 chars to avoid screenshot leakage,
|
||||
# show explicit "(none)" in anonymous mode so users don't paste blank fields.
|
||||
local _key_display
|
||||
if [[ -z "$key" ]]; then
|
||||
_key_display="(none — anonymous mode; most external IDEs require a non-empty API Key)"
|
||||
elif [[ ${#key} -gt 16 ]]; then
|
||||
_key_display="${key:0:8}...${key: -4}"
|
||||
else
|
||||
_key_display="$key"
|
||||
fi
|
||||
|
||||
# Detect Cline (VS Code extension saoudrizwan.claude-dev, see issue #12)
|
||||
if echo "$_vscode_exts" | grep -qiE 'cline|saoudrizwan\.claude-dev'; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • Cline: VSCode → Cline panel → Settings → API Provider = \"OpenAI Compatible\""
|
||||
echo " Base URL: $base_url/v1"
|
||||
echo " API Key: $_key_display"
|
||||
echo " Model ID: $_model_ids"
|
||||
fi
|
||||
|
||||
# Detect Continue.dev (extension ID continue.continue or config file, see issue #12)
|
||||
if echo "$_vscode_exts" | grep -qi 'continue\.continue' \
|
||||
|| [[ -f "$HOME/.continue/config.yaml" ]] \
|
||||
|| [[ -f "$HOME/.continue/config.json" ]]; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • Continue.dev: edit ~/.continue/config.yaml — add under \`models:\` (top-level key):"
|
||||
echo " models:"
|
||||
echo " - name: OCP Sonnet"
|
||||
echo " provider: openai"
|
||||
echo " model: claude-sonnet-4-6"
|
||||
echo " apiBase: $base_url/v1"
|
||||
echo " apiKey: $_key_display"
|
||||
echo " (other model IDs: $_model_ids)"
|
||||
fi
|
||||
|
||||
# Detect Cursor (command, ~/.cursor dir, or /Applications/Cursor.app, see issue #12)
|
||||
if command -v cursor &>/dev/null \
|
||||
|| [[ -d "$HOME/.cursor" ]] \
|
||||
|| [[ -d "/Applications/Cursor.app" ]]; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • Cursor: Cmd+Shift+P → 'Cursor Settings' → Models →"
|
||||
echo " OpenAI API Key: $_key_display"
|
||||
echo " Override OpenAI Base URL: $base_url/v1"
|
||||
echo " Custom OpenAI Models: $_model_ids"
|
||||
fi
|
||||
|
||||
# Detect opencode (https://opencode.ai, SST team CLI, see issue #12)
|
||||
if command -v opencode &>/dev/null \
|
||||
|| [[ -x "$HOME/.opencode/bin/opencode" ]] \
|
||||
|| [[ -d "$HOME/.local/share/opencode" ]]; then
|
||||
if ! $other_ides_shown; then
|
||||
echo " Other IDEs detected:"
|
||||
other_ides_shown=true
|
||||
fi
|
||||
echo " • opencode: not yet auto-configured by ocp-connect (PR follow-up)."
|
||||
echo " Run \`opencode providers login openai\` and provide:"
|
||||
echo " Base URL: $base_url/v1"
|
||||
echo " API Key: $_key_display"
|
||||
echo " Available model IDs: $_model_ids"
|
||||
fi
|
||||
|
||||
if $other_ides_shown; then
|
||||
echo ""
|
||||
fi
|
||||
}
|
||||
|
||||
main() {
|
||||
local host="" port=3456 key=""
|
||||
|
||||
# Parse args
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--port) port="${2:?'--port requires a value'}"; shift 2 ;;
|
||||
--key) key="${2:?'--key requires a value'}"
|
||||
[[ -z "$key" ]] && { echo "Error: --key cannot be empty (omit --key entirely for anonymous mode)"; exit 1; }
|
||||
shift 2 ;;
|
||||
--version) show_version; exit 0 ;;
|
||||
--help|-h) show_help; exit 0 ;;
|
||||
-*) echo "Unknown option: $1"; show_help; exit 1 ;;
|
||||
*) host="$1"; shift ;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$host" ]]; then
|
||||
echo "Error: host IP is required."
|
||||
echo ""
|
||||
show_help
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! [[ "$host" =~ ^[a-zA-Z0-9._-]+$ ]]; then
|
||||
echo "Error: invalid host '$host'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check dependencies
|
||||
for cmd in curl python3; do
|
||||
if ! command -v "$cmd" &>/dev/null; then
|
||||
echo "Error: '$cmd' is required but not found."
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
local base_url="http://$host:$port"
|
||||
|
||||
echo "OCP Connect v$OCP_CONNECT_VERSION"
|
||||
echo "─────────────────────────────────────"
|
||||
echo " Remote: $base_url"
|
||||
echo ""
|
||||
|
||||
# Step 1: Test connectivity via /health
|
||||
echo " Checking connectivity..."
|
||||
local health_json
|
||||
health_json=$(curl -sf --max-time 10 "$base_url/health" 2>/dev/null) || {
|
||||
echo " ✗ Cannot reach $base_url/health"
|
||||
echo " Make sure OCP is running on $host and bound to 0.0.0.0 (LAN mode)."
|
||||
exit 1
|
||||
}
|
||||
echo " ✓ Connected"
|
||||
echo ""
|
||||
|
||||
# Step 2: Show remote info
|
||||
local remote_version auth_mode
|
||||
remote_version=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('version','?'))" 2>/dev/null || echo "?")
|
||||
auth_mode=$(echo "$health_json" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d.get('authMode','none'))" 2>/dev/null || echo "none")
|
||||
|
||||
echo " Remote OCP v$remote_version (auth: $auth_mode)"
|
||||
echo ""
|
||||
|
||||
# Step 2.5: auto-discover anonymous key from /health (issue #12 §14 Path A).
|
||||
# When the OCP admin set PROXY_ANONYMOUS_KEY, the server advertises it via
|
||||
# /health.anonymousKey. If the user didn't pass --key, use it automatically so
|
||||
# `ocp-connect <host>` works zero-config for OpenClaw multi-agent setups.
|
||||
if [[ -z "$key" ]]; then
|
||||
local anon_key
|
||||
anon_key=$(echo "$health_json" | python3 -c "
|
||||
import sys, json
|
||||
try:
|
||||
d = json.loads(sys.stdin.read())
|
||||
k = d.get('anonymousKey')
|
||||
except Exception:
|
||||
k = None
|
||||
print(k if k else '')
|
||||
" 2>/dev/null || echo "")
|
||||
if [[ -n "$anon_key" ]]; then
|
||||
key="$anon_key"
|
||||
local _anon_display="$anon_key"
|
||||
if [[ ${#anon_key} -gt 16 ]]; then
|
||||
_anon_display="${anon_key:0:8}...${anon_key: -4}"
|
||||
fi
|
||||
echo " ⓘ Using server-advertised anonymous key: $_anon_display"
|
||||
echo " (set by admin via PROXY_ANONYMOUS_KEY; see issue #12 §14 Path A)"
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 3: Determine if key is needed
|
||||
if [[ "$auth_mode" != "none" && -z "$key" ]]; then
|
||||
# Try anonymous access first (zero-config: server may allow it)
|
||||
if curl -sf --max-time 5 "$base_url/v1/models" >/dev/null 2>&1; then
|
||||
echo " Server allows anonymous access — no key needed."
|
||||
echo ""
|
||||
else
|
||||
echo " Remote requires authentication."
|
||||
echo " Ask the OCP admin to run: ocp keys add <name>"
|
||||
printf " Enter API key (or press Enter to skip): "
|
||||
{ read -rs key </dev/tty; } 2>/dev/null || key=""
|
||||
echo
|
||||
if [[ -z "$key" ]]; then
|
||||
echo ""
|
||||
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
||||
echo " Use: ocp-connect $host --key <key>"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# Step 4: Test API access via /v1/models
|
||||
echo " Testing API access..."
|
||||
local models_out models_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
models_out=$(curl -sf --max-time 10 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
"$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
else
|
||||
models_out=$(curl -sf --max-time 10 "$base_url/v1/models" 2>/dev/null) && models_ok=1
|
||||
fi
|
||||
|
||||
if [[ $models_ok -eq 0 ]]; then
|
||||
echo " ✗ API access failed — key may be invalid or revoked."
|
||||
exit 1
|
||||
fi
|
||||
local model_count
|
||||
model_count=$(echo "$models_out" | python3 -c "import sys,json; print(len(json.loads(sys.stdin.read()).get('data',[])))" 2>/dev/null || echo "?")
|
||||
echo " ✓ API accessible ($model_count models available)"
|
||||
echo ""
|
||||
|
||||
# Step 5: Detect shell rc files and OS
|
||||
local rc_files=()
|
||||
local is_mac=false
|
||||
[[ "$(uname)" == "Darwin" ]] && is_mac=true
|
||||
|
||||
# Write to all relevant rc files
|
||||
if [[ "${SHELL:-}" == */fish ]]; then
|
||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||
rc_files+=("$HOME/.bashrc")
|
||||
else
|
||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
||||
[[ -f "$HOME/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
||||
# If neither exists, create for current shell
|
||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||
fi
|
||||
|
||||
# Step 6: Remove any previously written OCP LAN lines (idempotent) from all rc files
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
if [[ -f "$rc_file" ]]; then
|
||||
local tmp_rc
|
||||
tmp_rc=$(mktemp)
|
||||
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
||||
import sys
|
||||
src, dst = sys.argv[1], sys.argv[2]
|
||||
lines = open(src).readlines()
|
||||
out = []
|
||||
skip = False
|
||||
for line in lines:
|
||||
s = line.rstrip('\n')
|
||||
if s == '# OCP LAN (added by ocp connect)':
|
||||
skip = True
|
||||
continue
|
||||
if skip and (s == '' or s.startswith('export OPENAI_BASE_URL=') or s.startswith('export OPENAI_API_KEY=')):
|
||||
continue
|
||||
skip = False
|
||||
out.append(line)
|
||||
open(dst, 'w').writelines(out)
|
||||
PYEOF
|
||||
then
|
||||
cp "$tmp_rc" "$rc_file"
|
||||
fi
|
||||
rm -f "$tmp_rc"
|
||||
fi
|
||||
done
|
||||
|
||||
# Step 7: Append new config to all rc files
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
{
|
||||
echo ""
|
||||
echo "# OCP LAN (added by ocp connect)"
|
||||
echo "export OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "export OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} >> "$rc_file"
|
||||
done
|
||||
|
||||
echo " Shell config:"
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
echo " ✓ $(basename "$rc_file")"
|
||||
done
|
||||
echo " OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo " OPENAI_API_KEY=${key:0:8}..."
|
||||
fi
|
||||
|
||||
# Step 7b: System-level env vars (for IDEs, daemons, GUI apps)
|
||||
if $is_mac; then
|
||||
# macOS: launchctl setenv makes vars visible to all GUI apps and launchd services
|
||||
launchctl setenv OPENAI_BASE_URL "$base_url/v1" 2>/dev/null
|
||||
if [[ -n "$key" ]]; then
|
||||
launchctl setenv OPENAI_API_KEY "$key" 2>/dev/null
|
||||
fi
|
||||
echo ""
|
||||
echo " System-level (launchctl):"
|
||||
echo " ✓ OPENAI_BASE_URL set for GUI apps and daemons"
|
||||
echo " Note: launchctl vars reset on reboot. Add to Login Items or re-run ocp-connect."
|
||||
else
|
||||
# Linux: write to environment.d for systemd user services
|
||||
local env_dir="$HOME/.config/environment.d"
|
||||
mkdir -p "$env_dir" 2>/dev/null
|
||||
{
|
||||
echo "OPENAI_BASE_URL=$base_url/v1"
|
||||
if [[ -n "$key" ]]; then
|
||||
echo "OPENAI_API_KEY=$key"
|
||||
fi
|
||||
} > "$env_dir/ocp.conf"
|
||||
echo ""
|
||||
echo " System-level (systemd):"
|
||||
echo " ✓ $env_dir/ocp.conf"
|
||||
echo " Applies to systemd user services after re-login."
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# Step 7c: Interactive IDE configuration
|
||||
configure_ides "$base_url" "$key" "$models_out"
|
||||
|
||||
# Step 8: Quick smoke test
|
||||
echo " Running smoke test..."
|
||||
# Pick the first available model from /v1/models
|
||||
local smoke_model
|
||||
smoke_model=$(echo "$models_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['data'][0]['id'])" 2>/dev/null || echo "claude-haiku-4-5-20251001")
|
||||
local chat_payload="{\"model\":\"$smoke_model\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with OK only.\"}],\"max_tokens\":10}"
|
||||
local chat_out chat_ok=0
|
||||
if [[ -n "$key" ]]; then
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Authorization: Bearer $key" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
else
|
||||
chat_out=$(curl -sf --max-time 30 \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$chat_payload" \
|
||||
"$base_url/v1/chat/completions" 2>/dev/null) && chat_ok=1
|
||||
fi
|
||||
|
||||
if [[ $chat_ok -eq 1 ]]; then
|
||||
local reply
|
||||
reply=$(echo "$chat_out" | python3 -c "import sys,json; d=json.loads(sys.stdin.read()); print(d['choices'][0]['message']['content'].strip())" 2>/dev/null || echo "(response received)")
|
||||
echo " ✓ Smoke test passed: $reply"
|
||||
echo " Note: smoke test only verifies OCP is reachable and the key is valid."
|
||||
echo " It does not verify your IDE/agent end-to-end. To verify OpenClaw works,"
|
||||
echo " restart it (\`openclaw gateway restart\`) and send a test message to your bot."
|
||||
else
|
||||
echo " ⚠ Smoke test failed (proxy is reachable but chat completion did not succeed)."
|
||||
echo " The env vars have still been written. Check the remote OCP logs."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo " Done. Reload your shell to apply:"
|
||||
echo " source $rc_file"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "ocp",
|
||||
"name": "OCP Commands",
|
||||
"description": "Slash commands for the OpenClaw Proxy — /ocp usage, /ocp settings, /ocp health, etc.",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.1",
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ocp",
|
||||
"version": "3.1.0",
|
||||
"version": "3.3.1",
|
||||
"description": "Slash commands for the OpenClaw Proxy",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
Generated
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.4.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.4.0",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ocp": "ocp",
|
||||
"openclaw-claude-proxy": "server.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.1.0",
|
||||
"version": "3.9.0",
|
||||
"description": "OCP (Open Claude Proxy) — use your Claude Pro/Max subscription as an OpenAI-compatible API for any IDE. Works with Cline, OpenCode, Aider, Continue.dev, OpenClaw, and more.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
|
||||
+514
-191
@@ -1,30 +1,19 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* openclaw-claude-proxy v2.5.0 — OpenAI-compatible proxy for Claude CLI
|
||||
* openclaw-claude-proxy — OpenAI-compatible proxy for Claude CLI
|
||||
*
|
||||
* Translates OpenAI chat/completions requests into `claude -p` CLI calls,
|
||||
* letting you use your Claude Pro/Max subscription as an OpenClaw model provider.
|
||||
*
|
||||
* v2.5.0:
|
||||
* - Sliding-window circuit breaker: uses time-windowed failure rate instead of
|
||||
* consecutive-count, preventing multi-agent burst scenarios from tripping the
|
||||
* breaker too aggressively. Half-open state allows configurable probe requests.
|
||||
* - Graduated backoff: cooldown doubles on each re-open (capped at 5min),
|
||||
* resets fully on success.
|
||||
* - Health endpoint now exposes per-model breaker state and sliding window stats.
|
||||
* - Increased default timeout tiers for Opus/Sonnet to handle large agent prompts.
|
||||
*
|
||||
* v2.4.0:
|
||||
* - Per-model circuit breaker: consecutive timeouts temporarily mark a model as degraded
|
||||
* - Adaptive first-byte timeout: scales by model tier + prompt size
|
||||
* - Structured JSON logging for key events (easier to parse/alert on)
|
||||
* - On-demand spawning (no pool), session management, full tool access
|
||||
* Timeout design: single CLAUDE_TIMEOUT (default 600s / 10 min).
|
||||
* No separate first-byte or idle timeout — Claude tool-use causes long pauses
|
||||
* in the token stream (30s-5min) that make fine-grained timeouts unreliable.
|
||||
* This matches LiteLLM, OpenAI SDK, and other major LLM proxies.
|
||||
*
|
||||
* Env vars:
|
||||
* CLAUDE_PROXY_PORT — listen port (default: 3456)
|
||||
* CLAUDE_BIN — path to claude binary (default: auto-detect)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 300000)
|
||||
* CLAUDE_FIRST_BYTE_TIMEOUT — base first-byte timeout in ms (default: 90000)
|
||||
* CLAUDE_TIMEOUT — per-request timeout in ms (default: 600000)
|
||||
* CLAUDE_ALLOWED_TOOLS — comma-separated tools to allow (default: expanded set)
|
||||
* CLAUDE_SKIP_PERMISSIONS — "true" to bypass all permission checks (default: false)
|
||||
* CLAUDE_SYSTEM_PROMPT — system prompt appended to all requests
|
||||
@@ -44,6 +33,7 @@ import { readFileSync, accessSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { validateKey, recordUsage, getUsageByKey, getUsageTimeline, getRecentUsage, createKey, listKeys, revokeKey, closeDb, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats } from "./keys.mjs";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
@@ -89,8 +79,7 @@ function resolveClaude() {
|
||||
// Settings marked with `let` can be changed at runtime via PATCH /settings.
|
||||
const PORT = parseInt(process.env.CLAUDE_PROXY_PORT || "3456", 10);
|
||||
const CLAUDE = resolveClaude();
|
||||
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "300000", 10);
|
||||
let BASE_FIRST_BYTE_TIMEOUT = parseInt(process.env.CLAUDE_FIRST_BYTE_TIMEOUT || "90000", 10);
|
||||
let TIMEOUT = parseInt(process.env.CLAUDE_TIMEOUT || "600000", 10);
|
||||
const PROXY_API_KEY = process.env.PROXY_API_KEY || "";
|
||||
const SKIP_PERMISSIONS = process.env.CLAUDE_SKIP_PERMISSIONS === "true";
|
||||
const ALLOWED_TOOLS = (process.env.CLAUDE_ALLOWED_TOOLS ||
|
||||
@@ -104,6 +93,19 @@ const BREAKER_THRESHOLD = parseInt(process.env.CLAUDE_BREAKER_THRESHOLD || "6",
|
||||
const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000", 10);
|
||||
const BREAKER_WINDOW = parseInt(process.env.CLAUDE_BREAKER_WINDOW || "300000", 10);
|
||||
const BREAKER_HALF_OPEN_MAX = parseInt(process.env.CLAUDE_BREAKER_HALF_OPEN_MAX || "2", 10);
|
||||
const BIND_ADDRESS = process.env.CLAUDE_BIND || "127.0.0.1";
|
||||
const NO_CONTEXT = process.env.CLAUDE_NO_CONTEXT === "true";
|
||||
const AUTH_MODE = process.env.CLAUDE_AUTH_MODE || (PROXY_API_KEY ? "shared" : "none");
|
||||
const ADMIN_KEY = process.env.OCP_ADMIN_KEY || "";
|
||||
const PROXY_ANONYMOUS_KEY = process.env.PROXY_ANONYMOUS_KEY || "";
|
||||
let CACHE_TTL = parseInt(process.env.CLAUDE_CACHE_TTL || "0", 10); // 0 = disabled, value in ms
|
||||
if (PROXY_ANONYMOUS_KEY && AUTH_MODE !== "multi") {
|
||||
console.warn("WARNING: PROXY_ANONYMOUS_KEY is set but AUTH_MODE is not 'multi' — anonymous key will be ignored");
|
||||
}
|
||||
|
||||
if (AUTH_MODE === "shared" && !PROXY_API_KEY) {
|
||||
console.warn("WARNING: AUTH_MODE=shared but PROXY_API_KEY is not set — all requests will pass unauthenticated");
|
||||
}
|
||||
|
||||
const VERSION = _pkg.version;
|
||||
const START_TIME = Date.now();
|
||||
@@ -176,6 +178,16 @@ const sessionCleanupInterval = setInterval(() => {
|
||||
}
|
||||
}, 60000);
|
||||
|
||||
// Cache cleanup: remove expired entries every 10 minutes
|
||||
const cacheCleanupInterval = setInterval(() => {
|
||||
if (CACHE_TTL > 0) {
|
||||
try {
|
||||
const cleaned = clearCache(CACHE_TTL);
|
||||
if (cleaned > 0) logEvent("info", "cache_cleanup", { expired: cleaned });
|
||||
} catch (e) { logEvent("error", "cache_cleanup_failed", { error: e.message }); }
|
||||
}
|
||||
}, 600000);
|
||||
|
||||
// ── Active child process tracking ────────────────────────────────────────
|
||||
const activeProcesses = new Set();
|
||||
|
||||
@@ -358,26 +370,13 @@ function messagesToPrompt(messages) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Model tier multipliers for first-byte timeout.
|
||||
// Opus is much slower to produce first token, especially with large contexts.
|
||||
let MODEL_TIMEOUT_TIERS = {
|
||||
"opus": { base: 150000, perPromptChar: 0.00050 }, // 150s base + ~50s per 100k chars
|
||||
"sonnet": { base: 120000, perPromptChar: 0.00050 }, // 120s base + ~50s per 100k chars
|
||||
"haiku": { base: 45000, perPromptChar: 0.00010 }, // 45s base + ~10s per 100k chars
|
||||
};
|
||||
|
||||
// Model tier — used for logging only (no timeout logic).
|
||||
function getModelTier(cliModel) {
|
||||
if (cliModel.includes("opus")) return "opus";
|
||||
if (cliModel.includes("haiku")) return "haiku";
|
||||
return "sonnet";
|
||||
}
|
||||
|
||||
function computeFirstByteTimeout(cliModel, promptLength) {
|
||||
const tier = MODEL_TIMEOUT_TIERS[getModelTier(cliModel)];
|
||||
const timeout = tier.base + Math.floor(promptLength * tier.perPromptChar);
|
||||
return Math.min(timeout, Math.max(TIMEOUT - 5000, 10000));
|
||||
}
|
||||
|
||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||
// timeouts. Returns context object or throws synchronously.
|
||||
@@ -433,11 +432,16 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
delete env.ANTHROPIC_BASE_URL;
|
||||
delete env.ANTHROPIC_AUTH_TOKEN;
|
||||
|
||||
// Pure API mode: suppress Claude Code context injection while preserving OAuth auth
|
||||
if (NO_CONTEXT) {
|
||||
env.CLAUDE_CODE_DISABLE_CLAUDE_MDS = "1";
|
||||
env.CLAUDE_CODE_DISABLE_AUTO_MEMORY = "1";
|
||||
}
|
||||
|
||||
const proc = spawn(CLAUDE, cliArgs, { env, stdio: ["pipe", "pipe", "pipe"] });
|
||||
activeProcesses.add(proc);
|
||||
|
||||
const t0 = Date.now();
|
||||
const firstByteTimeoutMs = computeFirstByteTimeout(cliModel, prompt.length);
|
||||
let gotFirstByte = false;
|
||||
let cleaned = false;
|
||||
|
||||
@@ -445,7 +449,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
if (cleaned) return;
|
||||
cleaned = true;
|
||||
clearTimeout(overallTimer);
|
||||
clearTimeout(firstByteTimer);
|
||||
stats.activeRequests--;
|
||||
}
|
||||
|
||||
@@ -459,7 +462,6 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
function markFirstByte() {
|
||||
if (!gotFirstByte) {
|
||||
gotFirstByte = true;
|
||||
clearTimeout(firstByteTimer);
|
||||
console.log(`[claude] first-byte model=${cliModel} elapsed=${Date.now() - t0}ms`);
|
||||
}
|
||||
}
|
||||
@@ -469,27 +471,17 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
||||
proc.stdin.end();
|
||||
|
||||
recordModelRequest(cliModel, prompt.length);
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, firstByteTimeout: firstByteTimeoutMs, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
logEvent("info", "claude_spawned", { model: cliModel, promptChars: prompt.length, timeout: TIMEOUT, tier: getModelTier(cliModel), session: conversationId ? conversationId.slice(0, 12) + "..." : "none" });
|
||||
|
||||
// First-byte timeout
|
||||
const firstByteTimer = setTimeout(() => {
|
||||
if (!gotFirstByte && !cleaned) {
|
||||
stats.timeouts++;
|
||||
recordModelError(cliModel, true);
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "first_byte_timeout", { model: cliModel, timeoutMs: firstByteTimeoutMs, promptChars: prompt.length });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
}
|
||||
}, firstByteTimeoutMs);
|
||||
|
||||
// Overall request timeout
|
||||
// Single request timeout — no separate first-byte timer.
|
||||
// Claude tool-use causes long pauses in the token stream (30s-5min),
|
||||
// making first-byte/idle timeouts unreliable. One generous timeout is simpler and correct.
|
||||
const overallTimer = setTimeout(() => {
|
||||
if (!cleaned) {
|
||||
stats.timeouts++;
|
||||
recordModelError(cliModel, true);
|
||||
breakerRecordTimeout(cliModel);
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT });
|
||||
logEvent("error", "request_timeout", { model: cliModel, timeoutMs: TIMEOUT, elapsed: Date.now() - t0 });
|
||||
try { proc.kill("SIGTERM"); } catch {}
|
||||
setTimeout(() => { try { proc.kill("SIGKILL"); } catch {} }, 5000);
|
||||
}
|
||||
@@ -552,7 +544,7 @@ function callClaude(model, messages, conversationId) {
|
||||
// ── Call claude CLI (real streaming) ─────────────────────────────────────
|
||||
// Pipes stdout from the claude process directly to SSE chunks as they arrive.
|
||||
// Each data chunk becomes a proper SSE event with delta content in real time.
|
||||
function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}) {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
|
||||
@@ -567,6 +559,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
let stderr = "";
|
||||
let headersSent = false;
|
||||
let totalChars = 0;
|
||||
let cachedContent = ""; // accumulate for cache write-back
|
||||
|
||||
function ensureHeaders() {
|
||||
if (headersSent || res.writableEnded || res.destroyed) return false;
|
||||
@@ -588,6 +581,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
markFirstByte();
|
||||
const text = d.toString();
|
||||
totalChars += text.length;
|
||||
if (CACHE_TTL > 0) cachedContent += text;
|
||||
|
||||
if (!ensureHeaders()) return;
|
||||
|
||||
@@ -607,6 +601,7 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
|
||||
if (code !== 0) {
|
||||
recordModelError(cliModel, false);
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: 0, elapsedMs: elapsed, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
logEvent("error", "claude_exit", { model: cliModel, code, signal: signal || "none", elapsed, stderr: stderr.slice(0, 300) });
|
||||
trackError(stderr.slice(0, 300) || `claude exit ${code}`);
|
||||
handleSessionFailure();
|
||||
@@ -624,7 +619,12 @@ function callClaudeStreaming(model, messages, conversationId, res) {
|
||||
} else {
|
||||
recordModelSuccess(cliModel, elapsed);
|
||||
breakerRecordSuccess(cliModel);
|
||||
try { recordUsage({ keyId: authInfo.keyId, keyName: authInfo.keyName, model, promptChars: messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0), responseChars: totalChars, elapsedMs: elapsed, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
logEvent("info", "claude_ok", { model: cliModel, chars: totalChars, elapsed, session: convId ? convId.slice(0, 12) + "..." : "none" });
|
||||
// Cache write-back for streaming
|
||||
if (CACHE_TTL > 0 && authInfo.cacheHash) {
|
||||
try { setCachedResponse(authInfo.cacheHash, model, cachedContent); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
|
||||
if (!headersSent) ensureHeaders();
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
@@ -680,41 +680,124 @@ function completionResponse(res, id, model, content) {
|
||||
}
|
||||
|
||||
// ── Plan usage probe ────────────────────────────────────────────────────
|
||||
// Reads the OAuth token from macOS keychain and makes a minimal API call
|
||||
// to Anthropic to capture rate-limit headers (plan usage info).
|
||||
// Caches the result for 5 minutes to avoid excessive API calls.
|
||||
// ── Plan usage probe ────────────────────────────────────────────────────
|
||||
// ALIGNMENT: mirrors Claude Code cli.js vE4 rate-limit header extraction.
|
||||
// DO NOT switch endpoints without grepping "anthropic-ratelimit-unified" in cli.js.
|
||||
// 2026-04-11 b87992f drift lesson: /api/oauth/usage is a hallucinated endpoint.
|
||||
// See ALIGNMENT.md for full history.
|
||||
//
|
||||
// Reads OAuth token (keychain / Linux credentials / CLAUDE_CODE_OAUTH_TOKEN env)
|
||||
// and makes a minimal /v1/messages request to capture anthropic-ratelimit-unified-*
|
||||
// headers. Caches the result for 5 minutes.
|
||||
|
||||
let usageCache = { data: null, fetchedAt: 0 };
|
||||
const USAGE_CACHE_TTL = 300000; // 5 min
|
||||
const USAGE_CACHE_TTL = 5 * 60 * 1000; // 5 min
|
||||
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
|
||||
function getOAuthToken() {
|
||||
// Try Linux file-based credentials first
|
||||
// Refresh backoff state — exponential 60s → 3600s.
|
||||
// Prevents tight loops hammering the token endpoint after a failure
|
||||
// (lesson from pre-fix session that burned through rate-limit in seconds).
|
||||
const OAUTH_REFRESH_MIN_BACKOFF = 60 * 1000;
|
||||
const OAUTH_REFRESH_MAX_BACKOFF = 3600 * 1000;
|
||||
let oauthRefreshBackoff = { nextAttemptAt: 0, currentDelay: OAUTH_REFRESH_MIN_BACKOFF };
|
||||
|
||||
function getOAuthCredentials() {
|
||||
// 1. Env var fallback — highest precedence for explicit overrides.
|
||||
if (process.env.CLAUDE_CODE_OAUTH_TOKEN) {
|
||||
return { accessToken: process.env.CLAUDE_CODE_OAUTH_TOKEN };
|
||||
}
|
||||
|
||||
// 2. Linux file-based credentials
|
||||
try {
|
||||
const credPath = join(homedir(), ".claude", ".credentials.json");
|
||||
const creds = JSON.parse(readFileSync(credPath, "utf8"));
|
||||
const token = creds?.claudeAiOauth?.accessToken;
|
||||
if (token) return token;
|
||||
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
|
||||
} catch { /* fall through to macOS keychain */ }
|
||||
|
||||
// Try macOS keychain
|
||||
// 3. macOS keychain (both label formats)
|
||||
for (const label of ["claude-code-credentials", "Claude Code-credentials"]) {
|
||||
try {
|
||||
const raw = execFileSync("security", [
|
||||
"find-generic-password", "-s", label, "-w"
|
||||
], { encoding: "utf8", timeout: 5000 }).trim();
|
||||
const creds = JSON.parse(raw);
|
||||
if (creds?.claudeAiOauth?.accessToken) return creds.claudeAiOauth;
|
||||
} catch { /* try next */ }
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function refreshOAuthToken(refreshToken) {
|
||||
const now = Date.now();
|
||||
if (now < oauthRefreshBackoff.nextAttemptAt) {
|
||||
logEvent("info", "oauth_refresh_backoff_skip", {
|
||||
waitMs: oauthRefreshBackoff.nextAttemptAt - now,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const raw = execFileSync("security", [
|
||||
"find-generic-password", "-s", "Claude Code-credentials", "-w"
|
||||
], { encoding: "utf8", timeout: 5000 }).trim();
|
||||
const creds = JSON.parse(raw);
|
||||
return creds?.claudeAiOauth?.accessToken || null;
|
||||
} catch {
|
||||
const resp = await fetch(OAUTH_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: refreshToken,
|
||||
client_id: OAUTH_CLIENT_ID,
|
||||
scope: "user:inference user:profile",
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.text();
|
||||
// Exponential backoff on failure
|
||||
oauthRefreshBackoff.nextAttemptAt = Date.now() + oauthRefreshBackoff.currentDelay;
|
||||
oauthRefreshBackoff.currentDelay = Math.min(
|
||||
oauthRefreshBackoff.currentDelay * 2,
|
||||
OAUTH_REFRESH_MAX_BACKOFF,
|
||||
);
|
||||
logEvent("warn", "oauth_refresh_failed", {
|
||||
status: resp.status,
|
||||
body: body.slice(0, 200),
|
||||
nextBackoffMs: oauthRefreshBackoff.currentDelay,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Reset backoff on success
|
||||
oauthRefreshBackoff.currentDelay = OAUTH_REFRESH_MIN_BACKOFF;
|
||||
oauthRefreshBackoff.nextAttemptAt = 0;
|
||||
return data.access_token || null;
|
||||
} catch (err) {
|
||||
oauthRefreshBackoff.nextAttemptAt = Date.now() + oauthRefreshBackoff.currentDelay;
|
||||
oauthRefreshBackoff.currentDelay = Math.min(
|
||||
oauthRefreshBackoff.currentDelay * 2,
|
||||
OAUTH_REFRESH_MAX_BACKOFF,
|
||||
);
|
||||
logEvent("warn", "oauth_refresh_error", {
|
||||
error: err.message,
|
||||
nextBackoffMs: oauthRefreshBackoff.currentDelay,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsageFromApi() {
|
||||
const token = getOAuthToken();
|
||||
if (!token) {
|
||||
return { error: "No OAuth token found in keychain" };
|
||||
const creds = getOAuthCredentials();
|
||||
if (!creds?.accessToken) {
|
||||
return { error: "No OAuth token found (keychain / ~/.claude/.credentials.json / CLAUDE_CODE_OAUTH_TOKEN)" };
|
||||
}
|
||||
|
||||
// Minimal API call to haiku (cheapest) with max_tokens=1 — we only need the headers
|
||||
let token = creds.accessToken;
|
||||
|
||||
// Pre-emptive refresh if token looks expired (5 min buffer, same as Claude Code)
|
||||
if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt && creds.refreshToken) {
|
||||
logEvent("info", "oauth_token_expired_refreshing");
|
||||
const newToken = await refreshOAuthToken(creds.refreshToken);
|
||||
if (newToken) token = newToken;
|
||||
}
|
||||
|
||||
// Minimal /v1/messages request — we only need the response headers.
|
||||
// Mirrors Claude Code cli.js vE4: headers anthropic-ratelimit-unified-{5h,7d}-{utilization,reset}.
|
||||
const body = JSON.stringify({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 1,
|
||||
@@ -724,101 +807,121 @@ async function fetchUsageFromApi() {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
|
||||
const doFetch = (bearerToken) => fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${bearerToken}`,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
try {
|
||||
const resp = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"x-api-key": token,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
let resp = await doFetch(token);
|
||||
|
||||
// 401 → try a single refresh-and-retry
|
||||
if (resp.status === 401 && creds.refreshToken) {
|
||||
logEvent("info", "oauth_usage_401_refreshing");
|
||||
const newToken = await refreshOAuthToken(creds.refreshToken);
|
||||
if (newToken) {
|
||||
token = newToken;
|
||||
resp = await doFetch(token);
|
||||
}
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Extract all rate-limit headers
|
||||
// Extract all rate-limit headers (we do not need the response body)
|
||||
const rl = {};
|
||||
for (const [k, v] of resp.headers) {
|
||||
if (k.startsWith("anthropic-ratelimit")) {
|
||||
rl[k] = v;
|
||||
}
|
||||
if (k.startsWith("anthropic-ratelimit")) rl[k] = v;
|
||||
}
|
||||
|
||||
// Parse into structured usage object
|
||||
const now = Date.now();
|
||||
const session5hUtil = parseFloat(rl["anthropic-ratelimit-unified-5h-utilization"] || "0");
|
||||
const session5hReset = parseInt(rl["anthropic-ratelimit-unified-5h-reset"] || "0", 10);
|
||||
const weekly7dUtil = parseFloat(rl["anthropic-ratelimit-unified-7d-utilization"] || "0");
|
||||
const weekly7dReset = parseInt(rl["anthropic-ratelimit-unified-7d-reset"] || "0", 10);
|
||||
const overageStatus = rl["anthropic-ratelimit-unified-overage-status"] || "unknown";
|
||||
const overageDisabledReason = rl["anthropic-ratelimit-unified-overage-disabled-reason"] || "";
|
||||
const status = rl["anthropic-ratelimit-unified-status"] || "unknown";
|
||||
const representativeClaim = rl["anthropic-ratelimit-unified-representative-claim"] || "";
|
||||
const fallbackPct = parseFloat(rl["anthropic-ratelimit-unified-fallback-percentage"] || "0");
|
||||
|
||||
function formatReset(epochSec) {
|
||||
if (!epochSec) return "unknown";
|
||||
const diff = epochSec * 1000 - now;
|
||||
if (diff <= 0) return "now";
|
||||
const h = Math.floor(diff / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
if (h > 24) {
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ${h % 24}h`;
|
||||
}
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
if (!resp.ok && Object.keys(rl).length === 0) {
|
||||
return { error: `Usage API returned ${resp.status} with no rate-limit headers` };
|
||||
}
|
||||
|
||||
function resetDay(epochSec) {
|
||||
if (!epochSec) return "";
|
||||
const d = new Date(epochSec * 1000);
|
||||
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
plan: {
|
||||
currentSession: {
|
||||
utilization: session5hUtil,
|
||||
percent: `${Math.round(session5hUtil * 100)}%`,
|
||||
resetsIn: formatReset(session5hReset),
|
||||
resetsAt: session5hReset ? new Date(session5hReset * 1000).toISOString() : null,
|
||||
resetsAtHuman: resetDay(session5hReset),
|
||||
},
|
||||
weeklyLimits: {
|
||||
allModels: {
|
||||
utilization: weekly7dUtil,
|
||||
percent: `${Math.round(weekly7dUtil * 100)}%`,
|
||||
resetsIn: formatReset(weekly7dReset),
|
||||
resetsAt: weekly7dReset ? new Date(weekly7dReset * 1000).toISOString() : null,
|
||||
resetsAtHuman: resetDay(weekly7dReset),
|
||||
},
|
||||
},
|
||||
extraUsage: {
|
||||
status: overageStatus,
|
||||
disabledReason: overageDisabledReason || undefined,
|
||||
},
|
||||
representativeClaim,
|
||||
fallbackPercentage: fallbackPct,
|
||||
},
|
||||
proxy: {
|
||||
totalRequests: stats.totalRequests,
|
||||
activeRequests: stats.activeRequests,
|
||||
errors: stats.errors,
|
||||
timeouts: stats.timeouts,
|
||||
uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`,
|
||||
},
|
||||
models: getModelStatsSnapshot(),
|
||||
_raw: rl,
|
||||
};
|
||||
return parseRateLimitHeaders(rl);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
return { error: `Failed to fetch usage: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function parseRateLimitHeaders(rl) {
|
||||
const now = Date.now();
|
||||
|
||||
const session5hUtil = parseFloat(rl["anthropic-ratelimit-unified-5h-utilization"] || "0");
|
||||
const session5hReset = parseInt(rl["anthropic-ratelimit-unified-5h-reset"] || "0", 10);
|
||||
const weekly7dUtil = parseFloat(rl["anthropic-ratelimit-unified-7d-utilization"] || "0");
|
||||
const weekly7dReset = parseInt(rl["anthropic-ratelimit-unified-7d-reset"] || "0", 10);
|
||||
const overageStatus = rl["anthropic-ratelimit-unified-overage-status"] || "unknown";
|
||||
const overageDisabledReason = rl["anthropic-ratelimit-unified-overage-disabled-reason"] || "";
|
||||
const status = rl["anthropic-ratelimit-unified-status"] || "unknown";
|
||||
const representativeClaim = rl["anthropic-ratelimit-unified-representative-claim"] || "";
|
||||
const fallbackPct = parseFloat(rl["anthropic-ratelimit-unified-fallback-percentage"] || "0");
|
||||
|
||||
function formatReset(epochSec) {
|
||||
if (!epochSec) return "unknown";
|
||||
const diff = epochSec * 1000 - now;
|
||||
if (diff <= 0) return "now";
|
||||
const h = Math.floor(diff / 3600000);
|
||||
const m = Math.floor((diff % 3600000) / 60000);
|
||||
if (h > 24) {
|
||||
const d = Math.floor(h / 24);
|
||||
return `${d}d ${h % 24}h`;
|
||||
}
|
||||
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
||||
}
|
||||
|
||||
function resetDay(epochSec) {
|
||||
if (!epochSec) return "";
|
||||
const d = new Date(epochSec * 1000);
|
||||
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
plan: {
|
||||
currentSession: {
|
||||
utilization: session5hUtil,
|
||||
percent: `${Math.round(session5hUtil * 100)}%`,
|
||||
resetsIn: formatReset(session5hReset),
|
||||
resetsAt: session5hReset ? new Date(session5hReset * 1000).toISOString() : null,
|
||||
resetsAtHuman: resetDay(session5hReset),
|
||||
},
|
||||
weeklyLimits: {
|
||||
allModels: {
|
||||
utilization: weekly7dUtil,
|
||||
percent: `${Math.round(weekly7dUtil * 100)}%`,
|
||||
resetsIn: formatReset(weekly7dReset),
|
||||
resetsAt: weekly7dReset ? new Date(weekly7dReset * 1000).toISOString() : null,
|
||||
resetsAtHuman: resetDay(weekly7dReset),
|
||||
},
|
||||
},
|
||||
extraUsage: {
|
||||
status: overageStatus,
|
||||
disabledReason: overageDisabledReason || undefined,
|
||||
},
|
||||
representativeClaim,
|
||||
fallbackPercentage: fallbackPct,
|
||||
},
|
||||
proxy: {
|
||||
totalRequests: stats.totalRequests,
|
||||
activeRequests: stats.activeRequests,
|
||||
errors: stats.errors,
|
||||
timeouts: stats.timeouts,
|
||||
uptime: `${Math.floor((now - START_TIME) / 3600000)}h ${Math.floor(((now - START_TIME) % 3600000) / 60000)}m`,
|
||||
},
|
||||
models: getModelStatsSnapshot(),
|
||||
_raw: rl,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUsage(_req, res) {
|
||||
const now = Date.now();
|
||||
let data;
|
||||
@@ -897,7 +1000,9 @@ async function handleStatus(_req, res) {
|
||||
usage = usageCache.data;
|
||||
} else {
|
||||
usage = await fetchUsageFromApi();
|
||||
if (!usage.error) usageCache = { data: usage, fetchedAt: now };
|
||||
if (!usage.error) {
|
||||
usageCache = { data: usage, fetchedAt: now };
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
@@ -929,31 +1034,20 @@ async function handleStatus(_req, res) {
|
||||
//
|
||||
// Tunable keys and their types/ranges:
|
||||
const SETTINGS_SCHEMA = {
|
||||
timeout: { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Overall request timeout" },
|
||||
firstByteTimeout: { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Base first-byte timeout" },
|
||||
timeout: { type: "number", min: 30000, max: 1800000, unit: "ms", desc: "Request timeout (default: 600s)" },
|
||||
maxConcurrent: { type: "number", min: 1, max: 32, unit: "", desc: "Max concurrent claude processes" },
|
||||
sessionTTL: { type: "number", min: 60000, max: 86400000, unit: "ms", desc: "Session idle expiry" },
|
||||
maxPromptChars: { type: "number", min: 10000, max: 1000000, unit: "chars", desc: "Prompt truncation limit" },
|
||||
"tiers.opus.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Opus base first-byte timeout" },
|
||||
"tiers.opus.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Opus per-char timeout addition" },
|
||||
"tiers.sonnet.base": { type: "number", min: 30000, max: 600000, unit: "ms", desc: "Sonnet base first-byte timeout" },
|
||||
"tiers.sonnet.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Sonnet per-char timeout addition" },
|
||||
"tiers.haiku.base": { type: "number", min: 15000, max: 300000, unit: "ms", desc: "Haiku base first-byte timeout" },
|
||||
"tiers.haiku.perChar": { type: "number", min: 0, max: 0.01, unit: "ms/char", desc: "Haiku per-char timeout addition" },
|
||||
cacheTTL: { type: "number", min: 0, max: 86400000, unit: "ms", desc: "Response cache TTL (0 = disabled)" },
|
||||
};
|
||||
|
||||
function getSettings() {
|
||||
return {
|
||||
timeout: { value: TIMEOUT, ...SETTINGS_SCHEMA.timeout },
|
||||
firstByteTimeout: { value: BASE_FIRST_BYTE_TIMEOUT, ...SETTINGS_SCHEMA.firstByteTimeout },
|
||||
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
|
||||
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
|
||||
maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars },
|
||||
tiers: {
|
||||
opus: { base: MODEL_TIMEOUT_TIERS.opus.base, perPromptChar: MODEL_TIMEOUT_TIERS.opus.perPromptChar },
|
||||
sonnet: { base: MODEL_TIMEOUT_TIERS.sonnet.base, perPromptChar: MODEL_TIMEOUT_TIERS.sonnet.perPromptChar },
|
||||
haiku: { base: MODEL_TIMEOUT_TIERS.haiku.base, perPromptChar: MODEL_TIMEOUT_TIERS.haiku.perPromptChar },
|
||||
},
|
||||
cacheTTL: { value: CACHE_TTL, ...SETTINGS_SCHEMA.cacheTTL },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -965,16 +1059,10 @@ function applySettingUpdate(key, value) {
|
||||
|
||||
switch (key) {
|
||||
case "timeout": TIMEOUT = value; break;
|
||||
case "firstByteTimeout": BASE_FIRST_BYTE_TIMEOUT = value; break;
|
||||
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
||||
case "sessionTTL": SESSION_TTL = value; break;
|
||||
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
|
||||
case "tiers.opus.base": MODEL_TIMEOUT_TIERS.opus.base = value; break;
|
||||
case "tiers.opus.perChar": MODEL_TIMEOUT_TIERS.opus.perPromptChar = value; break;
|
||||
case "tiers.sonnet.base": MODEL_TIMEOUT_TIERS.sonnet.base = value; break;
|
||||
case "tiers.sonnet.perChar": MODEL_TIMEOUT_TIERS.sonnet.perPromptChar = value; break;
|
||||
case "tiers.haiku.base": MODEL_TIMEOUT_TIERS.haiku.base = value; break;
|
||||
case "tiers.haiku.perChar": MODEL_TIMEOUT_TIERS.haiku.perPromptChar = value; break;
|
||||
case "cacheTTL": CACHE_TTL = value; break;
|
||||
default: return `${key}: not implemented`;
|
||||
}
|
||||
logEvent("info", "setting_changed", { key, value });
|
||||
@@ -1051,16 +1139,69 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
if (!messages?.length) return jsonResponse(res, 400, { error: "messages required" });
|
||||
|
||||
if (stream) {
|
||||
// Real streaming: pipe stdout from claude process directly as SSE chunks
|
||||
return callClaudeStreaming(model, messages, conversationId, res);
|
||||
// Quota check — only for identified per-key users (not anonymous/admin/local)
|
||||
if (req._authKeyId) {
|
||||
let exceeded;
|
||||
try { exceeded = checkQuota(req._authKeyId, req._authKeyName); } catch (e) { logEvent("error", "quota_check_failed", { error: e.message }); exceeded = null; }
|
||||
if (exceeded) {
|
||||
logEvent("warn", "quota_exceeded", { keyId: req._authKeyId, keyName: req._authKeyName, period: exceeded.period, limit: exceeded.limit, used: exceeded.used });
|
||||
return jsonResponse(res, 429, {
|
||||
error: {
|
||||
message: `Quota exceeded: ${exceeded.used}/${exceeded.limit} requests (${exceeded.period}). Resets ${exceeded.resetsIn}.`,
|
||||
type: "quota_exceeded",
|
||||
quota: exceeded,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Cache check (only when cache is enabled and no active conversation/session)
|
||||
if (CACHE_TTL > 0 && !conversationId) {
|
||||
const hash = cacheHash(model, messages, { temperature: parsed.temperature, max_tokens: parsed.max_tokens, top_p: parsed.top_p });
|
||||
req._cacheHash = hash; // store for later write-back
|
||||
try {
|
||||
const cached = getCachedResponse(hash, CACHE_TTL);
|
||||
if (cached) {
|
||||
logEvent("info", "cache_hit", { model, hash: hash.slice(0, 12), hits: cached.hits });
|
||||
if (stream) {
|
||||
// Simulate streaming for cached response
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
const created = Math.floor(Date.now() / 1000);
|
||||
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
|
||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { role: "assistant" }, finish_reason: null }] });
|
||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: { content: cached.response }, finish_reason: null }] });
|
||||
sendSSE(res, { id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta: {}, finish_reason: "stop" }] });
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
return;
|
||||
} else {
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
return completionResponse(res, id, model, cached.response);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logEvent("error", "cache_check_failed", { error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
// Real streaming: pipe stdout from claude process directly as SSE chunks
|
||||
return callClaudeStreaming(model, messages, conversationId, res, { keyId: req._authKeyId, keyName: req._authKeyName, cacheHash: req._cacheHash });
|
||||
}
|
||||
|
||||
const t0Usage = Date.now();
|
||||
const promptChars = messages.reduce((a, m) => a + (typeof m.content === "string" ? m.content.length : JSON.stringify(m.content).length), 0);
|
||||
try {
|
||||
const content = await callClaude(model, messages, conversationId);
|
||||
const id = `chatcmpl-${randomUUID()}`;
|
||||
completionResponse(res, id, model, content);
|
||||
// Write to cache
|
||||
if (CACHE_TTL > 0 && req._cacheHash) {
|
||||
try { setCachedResponse(req._cacheHash, model, content); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||
}
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: content.length, elapsedMs: Date.now() - t0Usage, success: true }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
} catch (err) {
|
||||
try { recordUsage({ keyId: req._authKeyId, keyName: req._authKeyName, model, promptChars, responseChars: 0, elapsedMs: Date.now() - t0Usage, success: false }); } catch (e) { logEvent("error", "usage_record_failed", { error: e.message }); }
|
||||
console.error(`[proxy] error: ${err.message}`);
|
||||
if (res.headersSent || res.writableEnded || res.destroyed) {
|
||||
try { res.end(); } catch {}
|
||||
@@ -1074,25 +1215,97 @@ async function handleChatCompletions(req, res) {
|
||||
|
||||
// ── HTTP server ─────────────────────────────────────────────────────────
|
||||
const server = createServer(async (req, res) => {
|
||||
// Dynamic CORS: only allow localhost origins
|
||||
// Dynamic CORS: allow localhost and LAN origins
|
||||
const origin = req.headers["origin"] || "";
|
||||
const isLocalhost = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/.test(origin);
|
||||
res.setHeader("Access-Control-Allow-Origin", isLocalhost ? origin : `http://127.0.0.1:${PORT}`);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
|
||||
const isAllowedOrigin = /^https?:\/\/(127\.0\.0\.1|localhost|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|10\.\d+\.\d+\.\d+)(:\d+)?$/.test(origin);
|
||||
res.setHeader("Access-Control-Allow-Origin", isAllowedOrigin ? origin : `http://127.0.0.1:${PORT}`);
|
||||
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS, PATCH");
|
||||
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization, X-Session-Id, X-Conversation-Id");
|
||||
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||
|
||||
// Bearer token auth (skip for /health and when PROXY_API_KEY is not set)
|
||||
if (PROXY_API_KEY && req.url !== "/health") {
|
||||
// 3-mode auth: none | shared | multi
|
||||
const pathname = req.url.split("?")[0];
|
||||
const isPublicEndpoint = pathname === "/health" || pathname === "/dashboard";
|
||||
const remoteAddr = req.socket.remoteAddress || "";
|
||||
const isLocalhost = remoteAddr === "127.0.0.1" || remoteAddr === "::1" || remoteAddr === "::ffff:127.0.0.1";
|
||||
let authKeyName = isLocalhost ? "local" : "remote";
|
||||
let authKeyId = null;
|
||||
|
||||
if (!isPublicEndpoint) {
|
||||
const auth = req.headers["authorization"] || "";
|
||||
const token = auth.startsWith("Bearer ") ? auth.slice(7) : "";
|
||||
const tokenBuf = Buffer.from(token);
|
||||
const keyBuf = Buffer.from(PROXY_API_KEY);
|
||||
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
|
||||
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
|
||||
|
||||
if (isLocalhost) {
|
||||
// Localhost always allowed — try to identify key if provided, but never reject
|
||||
if (token) {
|
||||
if (ADMIN_KEY) {
|
||||
const adminBuf = Buffer.from(ADMIN_KEY);
|
||||
const tokenBuf = Buffer.from(token);
|
||||
if (adminBuf.length === tokenBuf.length && timingSafeEqual(adminBuf, tokenBuf)) {
|
||||
authKeyName = "admin";
|
||||
}
|
||||
}
|
||||
if (authKeyName !== "admin" && PROXY_ANONYMOUS_KEY) {
|
||||
// anonymous allowlist (issue #12 §14 Path A) — same check as multi branch
|
||||
const anonBuf = Buffer.from(PROXY_ANONYMOUS_KEY);
|
||||
const tokenBufA = Buffer.from(token);
|
||||
if (anonBuf.length === tokenBufA.length && timingSafeEqual(anonBuf, tokenBufA)) {
|
||||
authKeyName = "anonymous";
|
||||
}
|
||||
}
|
||||
if (authKeyName !== "admin" && authKeyName !== "anonymous") {
|
||||
const keyInfo = validateKey(token);
|
||||
if (keyInfo) { authKeyName = keyInfo.name; authKeyId = keyInfo.id; }
|
||||
}
|
||||
}
|
||||
} else if (AUTH_MODE === "shared") {
|
||||
if (PROXY_API_KEY) {
|
||||
const tokenBuf = Buffer.from(token);
|
||||
const keyBuf = Buffer.from(PROXY_API_KEY);
|
||||
if (tokenBuf.length !== keyBuf.length || !timingSafeEqual(tokenBuf, keyBuf)) {
|
||||
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or missing Bearer token", type: "auth_error" } });
|
||||
}
|
||||
authKeyName = "shared";
|
||||
}
|
||||
} else if (AUTH_MODE === "multi") {
|
||||
// If a token is provided, validate it; if not, allow as anonymous
|
||||
if (token) {
|
||||
let isAdminToken = false;
|
||||
if (ADMIN_KEY) {
|
||||
const adminBuf = Buffer.from(ADMIN_KEY);
|
||||
const tokenBuf2 = Buffer.from(token);
|
||||
if (adminBuf.length === tokenBuf2.length && timingSafeEqual(adminBuf, tokenBuf2)) {
|
||||
authKeyName = "admin";
|
||||
isAdminToken = true;
|
||||
}
|
||||
}
|
||||
// === NEW: anonymous allowlist (issue #12 §14 Path A) ===
|
||||
let isAnonymousToken = false;
|
||||
if (!isAdminToken && PROXY_ANONYMOUS_KEY) {
|
||||
const anonBuf = Buffer.from(PROXY_ANONYMOUS_KEY);
|
||||
const tokenBuf3 = Buffer.from(token);
|
||||
if (anonBuf.length === tokenBuf3.length && timingSafeEqual(anonBuf, tokenBuf3)) {
|
||||
authKeyName = "anonymous";
|
||||
isAnonymousToken = true;
|
||||
}
|
||||
}
|
||||
if (!isAdminToken && !isAnonymousToken) {
|
||||
const keyInfo = validateKey(token);
|
||||
if (!keyInfo) {
|
||||
return jsonResponse(res, 401, { error: { message: "Unauthorized: invalid or revoked API key", type: "auth_error" } });
|
||||
}
|
||||
authKeyName = keyInfo.name;
|
||||
authKeyId = keyInfo.id;
|
||||
}
|
||||
} else {
|
||||
authKeyName = "anonymous";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
req._authKeyName = authKeyName;
|
||||
req._authKeyId = authKeyId;
|
||||
|
||||
// GET /v1/models
|
||||
if (req.url === "/v1/models" && req.method === "GET") {
|
||||
return jsonResponse(res, 200, {
|
||||
@@ -1133,10 +1346,11 @@ const server = createServer(async (req, res) => {
|
||||
uptimeHuman: `${Math.floor(uptimeMs / 3600000)}h ${Math.floor((uptimeMs % 3600000) / 60000)}m`,
|
||||
claudeBinary: CLAUDE,
|
||||
claudeBinaryOk: binaryOk,
|
||||
authMode: AUTH_MODE,
|
||||
anonymousKey: PROXY_ANONYMOUS_KEY || null,
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
firstByteTimeout: BASE_FIRST_BYTE_TIMEOUT,
|
||||
maxConcurrent: MAX_CONCURRENT,
|
||||
sessionTTL: SESSION_TTL,
|
||||
circuitBreaker: "disabled",
|
||||
@@ -1188,7 +1402,108 @@ const server = createServer(async (req, res) => {
|
||||
return handleSettings(req, res);
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions" });
|
||||
// ── Key management API ──
|
||||
const isAdmin = AUTH_MODE !== "multi" || authKeyName === "admin" || isLocalhost;
|
||||
|
||||
if (req.url === "/api/keys" && req.method === "POST") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
let body = "";
|
||||
for await (const chunk of req) body += chunk;
|
||||
let parsed;
|
||||
try { parsed = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
const name = parsed.name || `key-${Date.now()}`;
|
||||
const newKey = createKey(name);
|
||||
return jsonResponse(res, 201, newKey);
|
||||
}
|
||||
|
||||
if (req.url === "/api/keys" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
return jsonResponse(res, 200, { keys: listKeys() });
|
||||
}
|
||||
|
||||
if (req.url?.startsWith("/api/keys/") && !req.url.includes("/quota") && req.method === "DELETE") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1]);
|
||||
const revoked = revokeKey(idOrName);
|
||||
return jsonResponse(res, 200, { revoked, idOrName });
|
||||
}
|
||||
|
||||
// PATCH /api/keys/:id/quota — set quota for a key
|
||||
// Body: { "daily": 100, "weekly": 500, "monthly": 2000 } (null = unlimited)
|
||||
if (req.url?.match(/^\/api\/keys\/[^/]+\/quota$/) && req.method === "PATCH") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1].replace("/quota", ""));
|
||||
let body = "";
|
||||
for await (const chunk of req) { body += chunk; if (body.length > 10000) return jsonResponse(res, 413, { error: "Body too large" }); }
|
||||
let quotaBody;
|
||||
try { quotaBody = JSON.parse(body); } catch { return jsonResponse(res, 400, { error: "Invalid JSON" }); }
|
||||
// Validate quota values: must be positive integers or null
|
||||
const quotaFields = {};
|
||||
for (const k of ["daily", "weekly", "monthly"]) {
|
||||
if (k in quotaBody) {
|
||||
const v = quotaBody[k];
|
||||
if (v !== null && (!Number.isInteger(v) || v < 0)) {
|
||||
return jsonResponse(res, 400, { error: `${k} must be a positive integer or null` });
|
||||
}
|
||||
quotaFields[k] = v;
|
||||
}
|
||||
}
|
||||
if (Object.keys(quotaFields).length === 0) return jsonResponse(res, 400, { error: "Provide at least one of: daily, weekly, monthly" });
|
||||
const updated = updateKeyQuota(idOrName, quotaFields);
|
||||
if (!updated) return jsonResponse(res, 404, { error: "Key not found" });
|
||||
logEvent("info", "quota_updated", { idOrName, ...quotaFields });
|
||||
return jsonResponse(res, 200, { ok: true, idOrName, quota: quotaFields });
|
||||
}
|
||||
|
||||
// GET /api/keys/:id/quota — get quota + current usage for a key
|
||||
if (req.url?.match(/^\/api\/keys\/[^/]+\/quota$/) && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const idOrName = decodeURIComponent(req.url.split("/api/keys/")[1].replace("/quota", ""));
|
||||
const keyRow = findKey(idOrName);
|
||||
if (!keyRow) return jsonResponse(res, 404, { error: "Key not found" });
|
||||
const quota = getKeyQuota(keyRow.id);
|
||||
return jsonResponse(res, 200, { keyId: keyRow.id, quota });
|
||||
}
|
||||
|
||||
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
||||
const since = url.searchParams.get("since");
|
||||
const until = url.searchParams.get("until");
|
||||
return jsonResponse(res, 200, {
|
||||
byKey: getUsageByKey({ since, until }),
|
||||
timeline: getUsageTimeline({ hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720) }),
|
||||
recent: getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500)),
|
||||
});
|
||||
}
|
||||
|
||||
// GET /cache/stats — cache statistics
|
||||
if (pathname === "/cache/stats" && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
return jsonResponse(res, 200, getCacheStats());
|
||||
}
|
||||
|
||||
// DELETE /cache — clear cache
|
||||
if (pathname === "/cache" && req.method === "DELETE") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
const cleared = clearCache();
|
||||
logEvent("info", "cache_cleared", { entries: cleared });
|
||||
return jsonResponse(res, 200, { cleared });
|
||||
}
|
||||
|
||||
// GET /dashboard — web dashboard
|
||||
if (pathname === "/dashboard" && req.method === "GET") {
|
||||
try {
|
||||
const html = readFileSync(join(__dirname, "dashboard.html"), "utf8");
|
||||
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||
res.end(html);
|
||||
} catch (err) {
|
||||
return jsonResponse(res, 500, { error: "Dashboard file not found" });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
jsonResponse(res, 404, { error: "Not found. Endpoints: GET /v1/models, POST /v1/chat/completions, GET /health, GET /usage, GET /status, GET /logs, GET|PATCH /settings, GET|DELETE /sessions, GET /dashboard, GET|POST|DELETE /api/keys, GET|PATCH /api/keys/:id/quota, GET /api/usage, GET /cache/stats, DELETE /cache" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1208,6 +1523,8 @@ function gracefulShutdown(signal) {
|
||||
// 2. Clear intervals/timers
|
||||
clearInterval(sessionCleanupInterval);
|
||||
clearInterval(authCheckInterval);
|
||||
clearInterval(cacheCleanupInterval);
|
||||
closeDb();
|
||||
|
||||
// 3. Kill all active child processes
|
||||
for (const proc of activeProcesses) {
|
||||
@@ -1245,18 +1562,24 @@ process.on("SIGTERM", () => gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => gracefulShutdown("SIGINT"));
|
||||
|
||||
// ── Start ───────────────────────────────────────────────────────────────
|
||||
server.listen(PORT, "127.0.0.1", () => {
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on http://127.0.0.1:${PORT}`);
|
||||
server.listen(PORT, BIND_ADDRESS, () => {
|
||||
const bindMsg = BIND_ADDRESS === "0.0.0.0" ? `http://0.0.0.0:${PORT} (LAN mode)` : `http://127.0.0.1:${PORT}`;
|
||||
console.log(`openclaw-claude-proxy v${VERSION} listening on ${bindMsg}`);
|
||||
console.log(`Architecture: on-demand spawning (no pool)`);
|
||||
console.log(`Models: ${MODELS.map((m) => m.id).join(", ")}`);
|
||||
console.log(`Claude binary: ${CLAUDE}`);
|
||||
console.log(`Timeout: ${TIMEOUT}ms (base first-byte: ${BASE_FIRST_BYTE_TIMEOUT}ms, adaptive by model/prompt) | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Timeout: ${TIMEOUT / 1000}s | Max concurrent: ${MAX_CONCURRENT}`);
|
||||
console.log(`Circuit breaker: disabled`);
|
||||
console.log(`Tools: ${SKIP_PERMISSIONS ? "all (skip-permissions)" : ALLOWED_TOOLS.join(", ")}`);
|
||||
console.log(`Sessions: TTL=${SESSION_TTL / 1000}s`);
|
||||
if (SYSTEM_PROMPT) console.log(`System prompt: "${SYSTEM_PROMPT.slice(0, 80)}..."`);
|
||||
if (MCP_CONFIG) console.log(`MCP config: ${MCP_CONFIG}`);
|
||||
console.log(`Auth: ${PROXY_API_KEY ? "enabled (PROXY_API_KEY set)" : "disabled (no PROXY_API_KEY)"}`);
|
||||
console.log(`Auth mode: ${AUTH_MODE}${AUTH_MODE === "shared" ? " (PROXY_API_KEY)" : AUTH_MODE === "multi" ? " (per-user keys)" : " (open)"}`);
|
||||
console.log(`Bind: ${BIND_ADDRESS}${BIND_ADDRESS === "0.0.0.0" ? " ⚠ LAN-accessible" : ""}`);
|
||||
if (NO_CONTEXT) console.log(`Context: suppressed (CLAUDE_NO_CONTEXT=true — no CLAUDE.md, no auto-memory)`);
|
||||
if (CACHE_TTL > 0) console.log(`Cache: enabled (TTL=${CACHE_TTL / 1000}s)`);
|
||||
else console.log(`Cache: disabled (set CLAUDE_CACHE_TTL to enable)`);
|
||||
console.log(`---`);
|
||||
console.log(`Coexistence: This proxy does NOT conflict with Claude Code interactive mode.`);
|
||||
console.log(` OCP uses: localhost:${PORT} (HTTP) → claude -p (per-request process)`);
|
||||
|
||||
@@ -36,6 +36,8 @@ const DEFAULT_MODEL = opt("default-model", "opus"); // opus | sonnet | haiku
|
||||
const DRY_RUN = flag("dry-run");
|
||||
const SKIP_START = flag("no-start");
|
||||
const PROVIDER_NAME = opt("provider-name", "claude-local");
|
||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
||||
|
||||
const MODEL_ID_MAP = {
|
||||
opus: "claude-opus-4-6",
|
||||
@@ -169,6 +171,19 @@ for (const [key, val] of Object.entries(MODEL_ALIASES)) {
|
||||
}
|
||||
log(`Model aliases added to agents.defaults.models`);
|
||||
|
||||
// Set idleTimeoutSeconds to 0 — critical for Claude tool-use.
|
||||
// When Claude calls tools (Bash, Read, etc.), the token stream pauses for 30-120s.
|
||||
// OpenClaw's default idleTimeoutSeconds (60s) kills the connection mid-tool-call,
|
||||
// causing exit 143 (SIGTERM) and stuck sessions. Setting to 0 disables the idle timer.
|
||||
if (!config.agents.defaults.llm) config.agents.defaults.llm = {};
|
||||
if (config.agents.defaults.llm.idleTimeoutSeconds === undefined ||
|
||||
config.agents.defaults.llm.idleTimeoutSeconds > 0) {
|
||||
config.agents.defaults.llm.idleTimeoutSeconds = 0;
|
||||
log(`Set agents.defaults.llm.idleTimeoutSeconds = 0 (prevents tool-call timeouts)`);
|
||||
} else {
|
||||
log(`idleTimeoutSeconds already configured: ${config.agents.defaults.llm.idleTimeoutSeconds}`);
|
||||
}
|
||||
|
||||
writeJSON(CONFIG_PATH, config);
|
||||
log(`Config saved`);
|
||||
|
||||
@@ -285,7 +300,16 @@ if (!DRY_RUN) {
|
||||
console.log("\n🔄 Installing auto-start on login...\n");
|
||||
|
||||
const platform = process.platform;
|
||||
const nodeBin = process.execPath;
|
||||
// Use stable symlink path instead of versioned Cellar path (e.g. /opt/homebrew/opt/node/bin/node
|
||||
// instead of /opt/homebrew/Cellar/node/25.8.0/bin/node) so the plist survives node upgrades.
|
||||
let nodeBin = process.execPath;
|
||||
if (platform === "darwin" && nodeBin.includes("/Cellar/")) {
|
||||
const stable = nodeBin.replace(/\/Cellar\/[^/]+\/[^/]+\//, "/opt/");
|
||||
if (existsSync(stable)) {
|
||||
nodeBin = stable;
|
||||
log(`Using stable node path: ${nodeBin}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure logs dir exists
|
||||
const logsDir = join(OPENCLAW_DIR, "logs");
|
||||
@@ -341,6 +365,10 @@ if (!DRY_RUN) {
|
||||
<dict>
|
||||
<key>CLAUDE_PROXY_PORT</key>
|
||||
<string>${PORT}</string>
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${AUTH_MODE_CONFIG}</string>
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -377,6 +405,8 @@ After=network.target
|
||||
[Service]
|
||||
ExecStart=${nodeBin} ${serverPath}
|
||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${logPath}
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Integration test for Quota + Cache features.
|
||||
* Tests database layer functions directly — no server needed.
|
||||
*/
|
||||
import { getDb, createKey, listKeys, validateKey, recordUsage, checkQuota, updateKeyQuota, getKeyQuota, findKey, cacheHash, getCachedResponse, setCachedResponse, clearCache, getCacheStats, closeDb } from "./keys.mjs";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { unlinkSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
|
||||
// Use a test database to avoid corrupting real data
|
||||
const TEST_DB = join(homedir(), ".ocp", "ocp-test.db");
|
||||
try { unlinkSync(TEST_DB); } catch {}
|
||||
|
||||
// Monkey-patch DB_PATH for testing (override the module-level variable)
|
||||
// Since keys.mjs uses lazy init, we can set env before first getDb() call
|
||||
process.env.HOME = homedir(); // ensure consistent
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
passed++;
|
||||
console.log(` ✓ ${name}`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(` ✗ ${name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n=== OCP Feature Tests (Quota + Cache) ===\n");
|
||||
|
||||
// Initialize DB
|
||||
const db = getDb();
|
||||
|
||||
// ── Quota Tests ──
|
||||
console.log("Quota:");
|
||||
|
||||
const key1 = createKey("test-user-1");
|
||||
const key2 = createKey("test-user-2");
|
||||
|
||||
test("createKey returns id, key, name", () => {
|
||||
assert.ok(key1.id);
|
||||
assert.ok(key1.key.startsWith("ocp_"));
|
||||
assert.equal(key1.name, "test-user-1");
|
||||
});
|
||||
|
||||
test("listKeys includes quota fields", () => {
|
||||
const keys = listKeys();
|
||||
assert.ok(keys.length >= 2);
|
||||
const k = keys.find(k => k.name === "test-user-1");
|
||||
assert.ok("quota_daily" in k);
|
||||
assert.ok("quota_weekly" in k);
|
||||
assert.ok("quota_monthly" in k);
|
||||
assert.equal(k.quota_daily, null);
|
||||
});
|
||||
|
||||
test("checkQuota returns null when no quota set", () => {
|
||||
const result = checkQuota(key1.id, key1.name);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("checkQuota returns null for null keyId", () => {
|
||||
assert.equal(checkQuota(null, "anon"), null);
|
||||
assert.equal(checkQuota(undefined, "anon"), null);
|
||||
});
|
||||
|
||||
test("updateKeyQuota sets daily quota (partial update)", () => {
|
||||
const ok = updateKeyQuota(key1.id, { daily: 5 });
|
||||
assert.ok(ok);
|
||||
const quota = getKeyQuota(key1.id);
|
||||
assert.equal(quota.daily.limit, 5);
|
||||
assert.equal(quota.weekly.limit, null); // not touched
|
||||
assert.equal(quota.monthly.limit, null);
|
||||
});
|
||||
|
||||
test("updateKeyQuota partial update preserves existing values", () => {
|
||||
updateKeyQuota(key1.id, { weekly: 20 });
|
||||
const quota = getKeyQuota(key1.id);
|
||||
assert.equal(quota.daily.limit, 5); // preserved from previous call
|
||||
assert.equal(quota.weekly.limit, 20);
|
||||
});
|
||||
|
||||
test("checkQuota passes when under limit", () => {
|
||||
// Record 3 usages (limit is 5 daily)
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
|
||||
}
|
||||
const result = checkQuota(key1.id, key1.name);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("checkQuota returns exceeded when at limit", () => {
|
||||
// Record 2 more to hit limit (3 + 2 = 5)
|
||||
for (let i = 0; i < 2; i++) {
|
||||
recordUsage({ keyId: key1.id, keyName: key1.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
|
||||
}
|
||||
const result = checkQuota(key1.id, key1.name);
|
||||
assert.ok(result);
|
||||
assert.equal(result.period, "daily");
|
||||
assert.equal(result.limit, 5);
|
||||
assert.equal(result.used, 5);
|
||||
assert.ok(result.resetsIn);
|
||||
});
|
||||
|
||||
test("checkQuota ignores failed requests in count", () => {
|
||||
// key2 has quota of 2 daily
|
||||
updateKeyQuota(key2.id, { daily: 2 });
|
||||
recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 0, elapsedMs: 500, success: false });
|
||||
recordUsage({ keyId: key2.id, keyName: key2.name, model: "sonnet", promptChars: 100, responseChars: 50, elapsedMs: 1000, success: true });
|
||||
const result = checkQuota(key2.id, key2.name);
|
||||
assert.equal(result, null); // only 1 successful, limit is 2
|
||||
});
|
||||
|
||||
test("getKeyQuota returns correct used counts", () => {
|
||||
const quota = getKeyQuota(key1.id);
|
||||
assert.equal(quota.daily.used, 5);
|
||||
assert.equal(quota.daily.limit, 5);
|
||||
});
|
||||
|
||||
test("findKey works by id and name", () => {
|
||||
const byId = findKey(String(key1.id));
|
||||
assert.ok(byId);
|
||||
assert.equal(byId.name, "test-user-1");
|
||||
const byName = findKey("test-user-1");
|
||||
assert.ok(byName);
|
||||
// Compare by name since auto-increment IDs may vary across runs
|
||||
assert.equal(byName.name, "test-user-1");
|
||||
assert.equal(findKey("nonexistent"), null);
|
||||
});
|
||||
|
||||
// ── Cache Tests ──
|
||||
console.log("\nCache:");
|
||||
|
||||
// Clean slate for cache tests
|
||||
clearCache();
|
||||
|
||||
const msgs1 = [{ role: "user", content: "Hello world" }];
|
||||
const msgs2 = [{ role: "user", content: "Different prompt" }];
|
||||
|
||||
test("cacheHash is deterministic", () => {
|
||||
const h1 = cacheHash("sonnet", msgs1);
|
||||
const h2 = cacheHash("sonnet", msgs1);
|
||||
assert.equal(h1, h2);
|
||||
});
|
||||
|
||||
test("cacheHash differs for different models", () => {
|
||||
const h1 = cacheHash("sonnet", msgs1);
|
||||
const h2 = cacheHash("opus", msgs1);
|
||||
assert.notEqual(h1, h2);
|
||||
});
|
||||
|
||||
test("cacheHash differs for different messages", () => {
|
||||
const h1 = cacheHash("sonnet", msgs1);
|
||||
const h2 = cacheHash("sonnet", msgs2);
|
||||
assert.notEqual(h1, h2);
|
||||
});
|
||||
|
||||
test("cacheHash includes temperature in hash", () => {
|
||||
const h1 = cacheHash("sonnet", msgs1, {});
|
||||
const h2 = cacheHash("sonnet", msgs1, { temperature: 0.5 });
|
||||
const h3 = cacheHash("sonnet", msgs1, { temperature: 1.0 });
|
||||
assert.notEqual(h1, h2);
|
||||
assert.notEqual(h2, h3);
|
||||
});
|
||||
|
||||
test("cacheHash includes max_tokens in hash", () => {
|
||||
const h1 = cacheHash("sonnet", msgs1, {});
|
||||
const h2 = cacheHash("sonnet", msgs1, { max_tokens: 100 });
|
||||
assert.notEqual(h1, h2);
|
||||
});
|
||||
|
||||
test("getCachedResponse returns null for miss", () => {
|
||||
const hash = cacheHash("sonnet", msgs1);
|
||||
const result = getCachedResponse(hash, 3600000);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("setCachedResponse + getCachedResponse roundtrip", () => {
|
||||
const hash = cacheHash("sonnet", msgs1);
|
||||
setCachedResponse(hash, "sonnet", "Hello! I am Claude.");
|
||||
const result = getCachedResponse(hash, 3600000);
|
||||
assert.ok(result);
|
||||
assert.equal(result.response, "Hello! I am Claude.");
|
||||
assert.equal(result.hits, 1);
|
||||
});
|
||||
|
||||
test("getCachedResponse increments hit counter", () => {
|
||||
const hash = cacheHash("sonnet", msgs1);
|
||||
const r1 = getCachedResponse(hash, 3600000);
|
||||
const r2 = getCachedResponse(hash, 3600000);
|
||||
assert.equal(r1.hits, 2);
|
||||
assert.equal(r2.hits, 3);
|
||||
});
|
||||
|
||||
test("getCachedResponse respects TTL (expired entry)", () => {
|
||||
// Insert a backdated cache entry directly
|
||||
const d = getDb();
|
||||
const oldHash = "test_expired_hash_12345";
|
||||
d.prepare("INSERT OR REPLACE INTO response_cache (hash, model, response, created_at) VALUES (?, ?, ?, datetime('now', '-2 hours'))").run(oldHash, "sonnet", "Old response");
|
||||
// TTL of 1 hour should not return a 2-hour-old entry
|
||||
const result = getCachedResponse(oldHash, 3600000);
|
||||
assert.equal(result, null);
|
||||
// Clean up the backdated entry so it doesn't affect subsequent tests
|
||||
d.prepare("DELETE FROM response_cache WHERE hash = ?").run(oldHash);
|
||||
});
|
||||
|
||||
test("getCacheStats returns correct counts", () => {
|
||||
const stats = getCacheStats();
|
||||
assert.equal(stats.entries, 1);
|
||||
assert.ok(stats.totalHits >= 3);
|
||||
assert.ok(stats.sizeBytes > 0);
|
||||
});
|
||||
|
||||
test("setCachedResponse upserts on conflict", () => {
|
||||
const hash = cacheHash("sonnet", msgs1);
|
||||
setCachedResponse(hash, "sonnet", "Updated response!");
|
||||
const result = getCachedResponse(hash, 3600000);
|
||||
assert.equal(result.response, "Updated response!");
|
||||
assert.equal(result.hits, 1); // reset after upsert
|
||||
});
|
||||
|
||||
test("clearCache removes all entries", () => {
|
||||
// Add another entry
|
||||
const hash2 = cacheHash("sonnet", msgs2);
|
||||
setCachedResponse(hash2, "sonnet", "Another response");
|
||||
const statsBefore = getCacheStats();
|
||||
assert.equal(statsBefore.entries, 2);
|
||||
|
||||
const cleared = clearCache();
|
||||
assert.equal(cleared, 2);
|
||||
|
||||
const statsAfter = getCacheStats();
|
||||
assert.equal(statsAfter.entries, 0);
|
||||
});
|
||||
|
||||
test("clearCache with TTL only removes old entries", () => {
|
||||
// Add fresh entry
|
||||
const hash = cacheHash("sonnet", msgs1);
|
||||
setCachedResponse(hash, "sonnet", "Fresh response");
|
||||
|
||||
// Clear with TTL of 1 hour — fresh entry should survive
|
||||
const cleared = clearCache(3600000);
|
||||
assert.equal(cleared, 0);
|
||||
|
||||
const stats = getCacheStats();
|
||||
assert.equal(stats.entries, 1);
|
||||
|
||||
// Clean up
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ── Cleanup ──
|
||||
closeDb();
|
||||
|
||||
console.log(`\n=== Results: ${passed} passed, ${failed} failed ===\n`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
Reference in New Issue
Block a user