mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -1,7 +1,5 @@
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
> **Status: Stable (v3.3.1)** — 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.8.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.8.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)
|
||||
@@ -132,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
|
||||
|
||||
```
|
||||
@@ -146,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
|
||||
|
||||
@@ -160,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
|
||||
|
||||
@@ -188,73 +483,72 @@ Add to `~/.openclaw/openclaw.json`:
|
||||
|
||||
Restart: `openclaw gateway restart`
|
||||
|
||||
## Troubleshooting
|
||||
### Telegram / Discord Usage
|
||||
|
||||
### Requests fail with exit 143 / SIGTERM after ~60 seconds
|
||||
After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
||||
|
||||
**Symptom:** Claude returns errors or stops responding after about 60 seconds, especially during tool use (Bash, Read, etc.).
|
||||
|
||||
**Cause:** OpenClaw's gateway has a default `idleTimeoutSeconds` of 60 seconds. When Claude calls tools, the token stream pauses while the tool executes — if that takes longer than 60s, the gateway kills the connection.
|
||||
|
||||
**Fix:** `setup.mjs` (v3.2.1+) sets this automatically. If you installed an older version, add this to `~/.openclaw/openclaw.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"agents": {
|
||||
"defaults": {
|
||||
"llm": {
|
||||
"idleTimeoutSeconds": 0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
/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
|
||||
```
|
||||
|
||||
Then restart: `openclaw gateway restart`
|
||||
> **Note:** Terminal CLI uses `ocp <command>`, Telegram/Discord uses `/ocp <command>`.
|
||||
|
||||
### Agents stuck in "typing" but never respond
|
||||
## Troubleshooting
|
||||
|
||||
Usually caused by stuck sessions from previous timeout errors. Fix:
|
||||
### Requests fail or agents stuck
|
||||
|
||||
```bash
|
||||
# Clear all sessions
|
||||
# Clear sessions and restart
|
||||
ocp clear
|
||||
|
||||
# Restart both services
|
||||
ocp restart
|
||||
|
||||
# If using OpenClaw gateway
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
If that doesn't help, manually clear the session store:
|
||||
### Usage shows "unknown"
|
||||
|
||||
Usually caused by an expired Claude CLI session. Fix:
|
||||
```bash
|
||||
# Find and reset stuck Telegram sessions
|
||||
cat ~/.openclaw/agents/main/sessions/sessions.json
|
||||
# Remove entries with "telegram" channel, then restart gateway
|
||||
claude auth login
|
||||
ocp restart
|
||||
```
|
||||
|
||||
## Upgrading from v3.0.x
|
||||
|
||||
If you installed OCP before v3.1.0, the auto-start service used names that OpenClaw's gateway detected as conflicting (`ai.openclaw.proxy` on macOS, `openclaw-proxy` on Linux). Running `node setup.mjs` or `ocp update` will automatically migrate to the new neutral names.
|
||||
|
||||
## 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` | `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)
|
||||
|
||||
## License
|
||||
|
||||
+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'
|
||||
@@ -442,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
|
||||
@@ -450,6 +804,9 @@ 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
|
||||
@@ -484,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:-}" ;;
|
||||
@@ -492,6 +849,9 @@ 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 ;;
|
||||
|
||||
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 "$@"
|
||||
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.3.1",
|
||||
"version": "3.8.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": {
|
||||
|
||||
+448
-118
@@ -33,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"));
|
||||
@@ -92,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();
|
||||
@@ -164,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();
|
||||
|
||||
@@ -408,6 +432,12 @@ 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);
|
||||
|
||||
@@ -514,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);
|
||||
|
||||
@@ -529,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;
|
||||
@@ -550,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;
|
||||
|
||||
@@ -569,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();
|
||||
@@ -586,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) {
|
||||
@@ -642,145 +680,191 @@ 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).
|
||||
// Uses the dedicated /api/oauth/usage endpoint (same as Claude Code CLI)
|
||||
// with Bearer auth + anthropic-beta header. Auto-refreshes expired tokens.
|
||||
// Caches the result for 5 minutes to avoid excessive API calls.
|
||||
|
||||
let usageCache = { data: null, fetchedAt: 0 };
|
||||
const USAGE_CACHE_TTL = 300000; // 5 min
|
||||
const USAGE_CACHE_TTL = 900000; // 15 min
|
||||
const OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
const OAUTH_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
||||
const OAUTH_BETA_HEADER = "oauth-2025-04-20";
|
||||
|
||||
function getOAuthToken() {
|
||||
function getOAuthCredentials() {
|
||||
// Try Linux file-based credentials first
|
||||
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
|
||||
// Try 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) {
|
||||
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();
|
||||
logEvent("warn", "oauth_refresh_failed", { status: resp.status, body: body.slice(0, 200) });
|
||||
return null;
|
||||
}
|
||||
const data = await resp.json();
|
||||
return data.access_token || null;
|
||||
} catch (err) {
|
||||
logEvent("warn", "oauth_refresh_error", { error: err.message });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsageFromApi() {
|
||||
const token = getOAuthToken();
|
||||
if (!token) {
|
||||
const creds = getOAuthCredentials();
|
||||
if (!creds?.accessToken) {
|
||||
return { error: "No OAuth token found in keychain" };
|
||||
}
|
||||
|
||||
// Minimal API call to haiku (cheapest) with max_tokens=1 — we only need the headers
|
||||
const body = JSON.stringify({
|
||||
model: "claude-haiku-4-5-20251001",
|
||||
max_tokens: 1,
|
||||
messages: [{ role: "user", content: "." }],
|
||||
});
|
||||
let token = creds.accessToken;
|
||||
|
||||
// Check if token looks expired (5 min buffer, same as Claude Code)
|
||||
if (creds.expiresAt && Date.now() + 300000 >= creds.expiresAt) {
|
||||
if (creds.refreshToken) {
|
||||
logEvent("info", "oauth_token_expired_refreshing");
|
||||
const newToken = await refreshOAuthToken(creds.refreshToken);
|
||||
if (newToken) token = newToken;
|
||||
}
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const resp = await fetch("https://api.anthropic.com/v1/messages", {
|
||||
method: "POST",
|
||||
const resp = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"x-api-key": token,
|
||||
"anthropic-version": "2023-06-01",
|
||||
"Authorization": `Bearer ${token}`,
|
||||
"anthropic-beta": OAUTH_BETA_HEADER,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
// Extract all rate-limit headers
|
||||
const rl = {};
|
||||
for (const [k, v] of resp.headers) {
|
||||
if (k.startsWith("anthropic-ratelimit")) {
|
||||
rl[k] = v;
|
||||
if (!resp.ok) {
|
||||
// If 401, try refreshing token once
|
||||
if (resp.status === 401 && creds.refreshToken) {
|
||||
logEvent("info", "oauth_usage_401_refreshing");
|
||||
const newToken = await refreshOAuthToken(creds.refreshToken);
|
||||
if (newToken) {
|
||||
const retryResp = await fetch("https://api.anthropic.com/api/oauth/usage", {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${newToken}`,
|
||||
"anthropic-beta": OAUTH_BETA_HEADER,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
if (retryResp.ok) {
|
||||
const retryData = await retryResp.json();
|
||||
return parseUsageResponse(retryData);
|
||||
}
|
||||
}
|
||||
return { error: `Usage API auth failed after refresh (${resp.status})` };
|
||||
}
|
||||
return { error: `Usage API returned ${resp.status}` };
|
||||
}
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
const data = await resp.json();
|
||||
return parseUsageResponse(data);
|
||||
} catch (err) {
|
||||
clearTimeout(timeout);
|
||||
return { error: `Failed to fetch usage: ${err.message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function parseUsageResponse(data) {
|
||||
const now = Date.now();
|
||||
|
||||
function formatReset(isoStr) {
|
||||
if (!isoStr) return "unknown";
|
||||
const diff = new Date(isoStr).getTime() - 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(isoStr) {
|
||||
if (!isoStr) return "";
|
||||
const d = new Date(isoStr);
|
||||
return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" });
|
||||
}
|
||||
|
||||
const fiveHour = data.five_hour || {};
|
||||
const sevenDay = data.seven_day || {};
|
||||
const extraUsage = data.extra_usage || {};
|
||||
|
||||
return {
|
||||
status: "active",
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
plan: {
|
||||
currentSession: {
|
||||
utilization: (fiveHour.utilization || 0) / 100,
|
||||
percent: `${Math.round(fiveHour.utilization || 0)}%`,
|
||||
resetsIn: formatReset(fiveHour.resets_at),
|
||||
resetsAt: fiveHour.resets_at || null,
|
||||
resetsAtHuman: resetDay(fiveHour.resets_at),
|
||||
},
|
||||
weeklyLimits: {
|
||||
allModels: {
|
||||
utilization: (sevenDay.utilization || 0) / 100,
|
||||
percent: `${Math.round(sevenDay.utilization || 0)}%`,
|
||||
resetsIn: formatReset(sevenDay.resets_at),
|
||||
resetsAt: sevenDay.resets_at || null,
|
||||
resetsAtHuman: resetDay(sevenDay.resets_at),
|
||||
},
|
||||
},
|
||||
extraUsage: {
|
||||
status: extraUsage.is_enabled ? "enabled" : "disabled",
|
||||
monthlyLimit: extraUsage.monthly_limit,
|
||||
usedCredits: extraUsage.used_credits,
|
||||
utilization: extraUsage.utilization,
|
||||
},
|
||||
},
|
||||
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: data,
|
||||
};
|
||||
}
|
||||
|
||||
async function handleUsage(_req, res) {
|
||||
const now = Date.now();
|
||||
let data;
|
||||
@@ -790,6 +874,9 @@ async function handleUsage(_req, res) {
|
||||
data = await fetchUsageFromApi();
|
||||
if (!data.error) {
|
||||
usageCache = { data, fetchedAt: now };
|
||||
} else if (usageCache.data) {
|
||||
// Fallback to stale cache on error (e.g. 429 rate limit)
|
||||
data = { ...usageCache.data, _stale: true, _fetchError: data.error };
|
||||
}
|
||||
}
|
||||
// Always attach live model stats and proxy stats (not cached)
|
||||
@@ -859,7 +946,11 @@ 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 };
|
||||
} else if (usageCache.data) {
|
||||
usage = { ...usageCache.data, _stale: true };
|
||||
}
|
||||
}
|
||||
|
||||
// Auth
|
||||
@@ -895,6 +986,7 @@ const SETTINGS_SCHEMA = {
|
||||
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" },
|
||||
cacheTTL: { type: "number", min: 0, max: 86400000, unit: "ms", desc: "Response cache TTL (0 = disabled)" },
|
||||
};
|
||||
|
||||
function getSettings() {
|
||||
@@ -903,6 +995,7 @@ function getSettings() {
|
||||
maxConcurrent: { value: MAX_CONCURRENT, ...SETTINGS_SCHEMA.maxConcurrent },
|
||||
sessionTTL: { value: SESSION_TTL, ...SETTINGS_SCHEMA.sessionTTL },
|
||||
maxPromptChars: { value: MAX_PROMPT_CHARS, ...SETTINGS_SCHEMA.maxPromptChars },
|
||||
cacheTTL: { value: CACHE_TTL, ...SETTINGS_SCHEMA.cacheTTL },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -917,6 +1010,7 @@ function applySettingUpdate(key, value) {
|
||||
case "maxConcurrent": MAX_CONCURRENT = value; break;
|
||||
case "sessionTTL": SESSION_TTL = value; break;
|
||||
case "maxPromptChars": MAX_PROMPT_CHARS = value; break;
|
||||
case "cacheTTL": CACHE_TTL = value; break;
|
||||
default: return `${key}: not implemented`;
|
||||
}
|
||||
logEvent("info", "setting_changed", { key, value });
|
||||
@@ -993,16 +1087,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 {}
|
||||
@@ -1016,25 +1163,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, {
|
||||
@@ -1075,6 +1294,8 @@ 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,
|
||||
@@ -1129,7 +1350,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" });
|
||||
});
|
||||
|
||||
|
||||
@@ -1149,6 +1471,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) {
|
||||
@@ -1186,8 +1510,9 @@ 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}`);
|
||||
@@ -1198,6 +1523,11 @@ server.listen(PORT, "127.0.0.1", () => {
|
||||
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",
|
||||
@@ -363,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/>
|
||||
@@ -399,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