mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3745fa8fe | ||
|
|
d71e0455e3 | ||
|
|
38070fbabb | ||
|
|
2adea6368d | ||
|
|
7860f71943 | ||
|
|
e326cee9dd | ||
|
|
f8ca9b85b0 | ||
|
|
c22b0dd074 | ||
|
|
7f46405152 | ||
|
|
cb6c2a8b5f | ||
|
|
02c6758a61 | ||
|
|
6d0e43ec37 | ||
|
|
b87992fa3b |
@@ -1,6 +1,6 @@
|
||||
# OCP — Open Claude Proxy
|
||||
|
||||
> **Status: Stable (v3.5.0)** — Feature-complete. Bug fixes only.
|
||||
> **Status: Stable (v3.7.0)** — Feature-complete. Bug fixes only.
|
||||
|
||||
> **Already paying for Claude Pro/Max? Use your subscription as an OpenAI-compatible API — $0 extra cost.**
|
||||
|
||||
@@ -111,42 +111,97 @@ curl http://127.0.0.1:3456/v1/models
|
||||
```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> --key <your-api-key>
|
||||
curl -fsSL https://raw.githubusercontent.com/dtzp555-max/ocp/main/ocp-connect | bash -s -- <server-ip>
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
$ ./ocp-connect 192.168.1.100 --key ocp_xDYzOB9ZKYzn
|
||||
$ ./ocp-connect 192.168.1.100
|
||||
|
||||
OCP Connect
|
||||
OCP Connect v1.3.0
|
||||
─────────────────────────────────────
|
||||
Remote: http://192.168.1.100:3456
|
||||
|
||||
Checking connectivity...
|
||||
✓ Connected
|
||||
|
||||
Remote OCP v3.5.0 (auth: multi)
|
||||
Remote OCP v3.7.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)
|
||||
|
||||
Written to /home/user/.bashrc:
|
||||
Shell config:
|
||||
✓ .bashrc
|
||||
✓ .zshrc
|
||||
OPENAI_BASE_URL=http://192.168.1.100:3456/v1
|
||||
OPENAI_API_KEY=ocp_xDYz...
|
||||
|
||||
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 /home/user/.bashrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
After running, reload your shell (`source ~/.bashrc` or `source ~/.zshrc`) and your IDE will automatically use the remote OCP.
|
||||
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.7.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
|
||||
@@ -183,6 +238,23 @@ ocp keys revoke son-ipad # Revoke a key
|
||||
| `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.
|
||||
|
||||
### Important Notes
|
||||
|
||||
- All users share your Claude Pro/Max **rate limits** (5h session + 7d weekly)
|
||||
@@ -288,7 +360,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
|
||||
|
||||
@@ -352,52 +424,25 @@ After installing the gateway plugin, use `/ocp` slash commands in your chat:
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Requests fail with exit 143 / SIGTERM after ~60 seconds
|
||||
|
||||
**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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then restart: `openclaw gateway restart`
|
||||
|
||||
### Agents stuck in "typing" but never respond
|
||||
|
||||
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 |
|
||||
@@ -413,7 +458,9 @@ If you installed OCP before v3.1.0, the auto-start service used names that OpenC
|
||||
| `CLAUDE_SESSION_TTL` | `3600000` | Session expiry (ms, default: 1 hour) |
|
||||
| `CLAUDE_ALLOWED_TOOLS` | `Bash,Read,...,Agent` | Comma-separated tools to pre-approve |
|
||||
| `CLAUDE_SKIP_PERMISSIONS` | `false` | Bypass all permission checks |
|
||||
| `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
|
||||
|
||||
|
||||
+521
-37
@@ -11,6 +11,12 @@
|
||||
#
|
||||
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)
|
||||
@@ -24,6 +30,7 @@ Usage:
|
||||
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:
|
||||
@@ -35,12 +42,406 @@ 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. Runs a smoke test to confirm everything works
|
||||
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=""
|
||||
|
||||
@@ -48,7 +449,10 @@ main() {
|
||||
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 ;;
|
||||
--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 ;;
|
||||
@@ -77,7 +481,7 @@ main() {
|
||||
|
||||
local base_url="http://$host:$port"
|
||||
|
||||
echo "OCP Connect"
|
||||
echo "OCP Connect v$OCP_CONNECT_VERSION"
|
||||
echo "─────────────────────────────────────"
|
||||
echo " Remote: $base_url"
|
||||
echo ""
|
||||
@@ -101,17 +505,51 @@ main() {
|
||||
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
|
||||
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
|
||||
echo
|
||||
if [[ -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 ""
|
||||
echo " ✗ No key provided — cannot connect to an auth-required remote without a key."
|
||||
exit 1
|
||||
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
|
||||
|
||||
@@ -135,22 +573,29 @@ main() {
|
||||
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"
|
||||
elif [[ "${SHELL:-}" == */fish ]]; then
|
||||
# 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_file="$HOME/.bashrc"
|
||||
rc_files+=("$HOME/.bashrc")
|
||||
else
|
||||
rc_file="$HOME/.bashrc"
|
||||
# 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)
|
||||
if [[ -f "$rc_file" ]]; then
|
||||
local tmp_rc
|
||||
tmp_rc=$(mktemp)
|
||||
if python3 - "$rc_file" "$tmp_rc" <<'PYEOF'
|
||||
# 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()
|
||||
@@ -167,29 +612,65 @@ for line in lines:
|
||||
out.append(line)
|
||||
open(dst, 'w').writelines(out)
|
||||
PYEOF
|
||||
then
|
||||
cp "$tmp_rc" "$rc_file"
|
||||
then
|
||||
cp "$tmp_rc" "$rc_file"
|
||||
fi
|
||||
rm -f "$tmp_rc"
|
||||
fi
|
||||
rm -f "$tmp_rc"
|
||||
fi
|
||||
done
|
||||
|
||||
# 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"
|
||||
# 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 " Written to $rc_file:"
|
||||
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
|
||||
@@ -214,6 +695,9 @@ PYEOF
|
||||
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."
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "openclaw-claude-proxy",
|
||||
"version": "3.5.0",
|
||||
"version": "3.7.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": {
|
||||
|
||||
+187
-103
@@ -94,8 +94,13 @@ const BREAKER_COOLDOWN = parseInt(process.env.CLAUDE_BREAKER_COOLDOWN || "120000
|
||||
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 || "";
|
||||
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");
|
||||
@@ -416,6 +421,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);
|
||||
|
||||
@@ -652,145 +663,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;
|
||||
@@ -800,6 +857,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)
|
||||
@@ -869,7 +929,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
|
||||
@@ -1060,7 +1124,15 @@ const server = createServer(async (req, res) => {
|
||||
authKeyName = "admin";
|
||||
}
|
||||
}
|
||||
if (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; }
|
||||
}
|
||||
@@ -1086,7 +1158,17 @@ const server = createServer(async (req, res) => {
|
||||
isAdminToken = true;
|
||||
}
|
||||
}
|
||||
if (!isAdminToken) {
|
||||
// === 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" } });
|
||||
@@ -1144,6 +1226,7 @@ const server = createServer(async (req, res) => {
|
||||
claudeBinary: CLAUDE,
|
||||
claudeBinaryOk: binaryOk,
|
||||
authMode: AUTH_MODE,
|
||||
anonymousKey: PROXY_ANONYMOUS_KEY || null,
|
||||
auth: authStatus,
|
||||
config: {
|
||||
timeout: TIMEOUT,
|
||||
@@ -1321,6 +1404,7 @@ server.listen(PORT, BIND_ADDRESS, () => {
|
||||
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)`);
|
||||
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)`);
|
||||
|
||||
Reference in New Issue
Block a user