mirror of
https://github.com/dtzp555-max/ocp.git
synced 2026-07-21 21:15:09 +00:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb43ef9ec | ||
|
|
a71c939bf8 | ||
|
|
d245c62df7 | ||
|
|
047750e642 | ||
|
|
3bdeb50ed5 | ||
|
|
fbbf3b6c7c | ||
|
|
d760d7fcce | ||
|
|
5e2effd05b | ||
|
|
fb2d1d3feb | ||
|
|
12b09c236e | ||
|
|
c0f2d3ab20 | ||
|
|
0d61da5153 |
@@ -6,6 +6,8 @@
|
||||
|
||||
*Open source from day one, used daily by my family, maintained on nights and weekends. If OCP saves you money too, you can [☕ buy me a coffee](https://buymeacoffee.com/dtzp555) — [full story below](#support-ocp).*
|
||||
|
||||
*If OCP saves you a setup, a ⭐ helps other folks discover it. Issue reports are even more useful — that's the highest-quality feedback this project gets.*
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
@@ -185,7 +187,8 @@ 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
|
||||
|
||||
After install the `ocp` CLI lives at `~/ocp/ocp`. To put it on your PATH, either symlink it manually (`ln -sf ~/ocp/ocp ~/.local/bin/ocp` if `~/.local/bin` is on your PATH, or `sudo ln -sf ~/ocp/ocp /usr/local/bin/ocp` for a system-wide symlink) or add an alias (`alias ocp=~/ocp/ocp`). Otherwise invoke it as `~/ocp/ocp <subcommand>`. The rest of this README assumes `ocp` is on your PATH.
|
||||
|
||||
**Single-machine use** — just set your IDE to use the proxy:
|
||||
```bash
|
||||
@@ -395,11 +398,15 @@ In `multi` mode, the admin can designate a single well-known "anonymous" key tha
|
||||
|
||||
**Enable**:
|
||||
|
||||
The anonymous key is wired into the service unit (launchd plist on macOS, systemd unit on Linux) at install time. Export `PROXY_ANONYMOUS_KEY` in your shell before running `setup.mjs`, and `setup.mjs` will write it into the service unit env so the auto-started proxy picks it up:
|
||||
|
||||
```bash
|
||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
||||
ocp start # or however you start the server
|
||||
node setup.mjs --bind 0.0.0.0 --auth-mode multi
|
||||
```
|
||||
|
||||
If OCP is already installed without it, re-export the env var and re-run `node setup.mjs` (the installer is idempotent — it refreshes the service unit). Then `ocp restart` so the running proxy picks up the new env. Setting `PROXY_ANONYMOUS_KEY` only in your interactive shell **does not** affect the auto-started proxy — the service unit is the source of truth for its environment.
|
||||
|
||||
**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.
|
||||
|
||||
+21
-2
@@ -55,7 +55,13 @@
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Usage by Key</h2>
|
||||
<div class="flex" style="justify-content: space-between; align-items: center;">
|
||||
<h2 style="margin: 0;">Usage by Key</h2>
|
||||
<label id="usage-scope-toggle" class="flex" style="display:none; gap: 0.4rem; font-size: 0.8rem; color: #94a3b8; cursor: pointer;">
|
||||
<input type="checkbox" id="usage-show-all" style="cursor: pointer;">
|
||||
<span>Show all keys</span>
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
@@ -170,7 +176,8 @@ async function refreshStatus() {
|
||||
|
||||
async function refreshUsage() {
|
||||
try {
|
||||
const data = await api("/api/usage");
|
||||
const showAll = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||
const data = await api(showAll ? "/api/usage?all=true" : "/api/usage");
|
||||
const tbody = document.querySelector("#key-usage-table tbody");
|
||||
tbody.innerHTML = (data.byKey || []).map(k => `
|
||||
<tr>
|
||||
@@ -204,6 +211,8 @@ async function refreshKeys() {
|
||||
try {
|
||||
const data = await api("/api/keys");
|
||||
document.getElementById("key-mgmt-section").style.display = "";
|
||||
// Admin-only "Show all keys" toggle for /api/usage scope.
|
||||
document.getElementById("usage-scope-toggle").style.display = "flex";
|
||||
const tbody = document.querySelector("#keys-table tbody");
|
||||
tbody.innerHTML = (data.keys || []).map(k => `
|
||||
<tr>
|
||||
@@ -240,6 +249,16 @@ async function refreshAll() {
|
||||
document.getElementById("refresh-indicator").textContent = `Updated ${new Date().toLocaleTimeString()}`;
|
||||
}
|
||||
|
||||
// Wire "Show all keys" toggle (visibility gated to admin via refreshKeys()).
|
||||
(function setupUsageScopeToggle() {
|
||||
const cb = document.getElementById("usage-show-all");
|
||||
cb.checked = localStorage.getItem("ocp_usage_show_all") === "1";
|
||||
cb.addEventListener("change", () => {
|
||||
localStorage.setItem("ocp_usage_show_all", cb.checked ? "1" : "0");
|
||||
refreshUsage();
|
||||
});
|
||||
})();
|
||||
|
||||
refreshAll();
|
||||
setInterval(refreshAll, 30000);
|
||||
</script>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 222 KiB After Width: | Height: | Size: 366 KiB |
@@ -8,21 +8,18 @@ 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=""
|
||||
# Auth args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||
# Using a bash array preserves word boundaries — no eval needed.
|
||||
_AUTH_ARGS=()
|
||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $OCP_ADMIN_KEY\""
|
||||
_AUTH_ARGS=(-H "Authorization: Bearer $OCP_ADMIN_KEY")
|
||||
elif [[ -f "$HOME/.ocp/admin-key" ]]; then
|
||||
_AUTH_HEADER="-H \"Authorization: Bearer $(cat "$HOME/.ocp/admin-key")\""
|
||||
_AUTH_ARGS=(-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
|
||||
curl "${_AUTH_ARGS[@]}" "$@"
|
||||
}
|
||||
|
||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||
|
||||
+13
-3
@@ -582,11 +582,19 @@ print(k if k else '')
|
||||
if [[ "${SHELL:-}" == */fish ]]; then
|
||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||
rc_files+=("$HOME/.bashrc")
|
||||
elif $is_mac; then
|
||||
# macOS: default shell since Catalina (2019) is zsh.
|
||||
# Always write ~/.zshrc (create if absent — zsh tolerates an empty file).
|
||||
# Only write ~/.bashrc if it already exists (don't surprise users with new files).
|
||||
[[ -f "$HOME/.bashrc" ]] && rc_files+=("$HOME/.bashrc")
|
||||
# zshrc: always include on macOS; create the file if it doesn't exist yet
|
||||
[[ -f "$HOME/.zshrc" ]] || touch "$HOME/.zshrc"
|
||||
rc_files+=("$HOME/.zshrc")
|
||||
else
|
||||
# Always write both on macOS (default shell is zsh but some tools source bashrc)
|
||||
# Linux / other: write to whichever rc files already exist or match current shell
|
||||
[[ -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
|
||||
# If neither exists, fall back to creating one for the current shell
|
||||
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||
fi
|
||||
|
||||
@@ -705,7 +713,9 @@ PYEOF
|
||||
|
||||
echo ""
|
||||
echo " Done. Reload your shell to apply:"
|
||||
echo " source $rc_file"
|
||||
for rc_file in "${rc_files[@]}"; do
|
||||
echo " source $rc_file"
|
||||
done
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
+83
-8
@@ -30,7 +30,7 @@
|
||||
import { createServer } from "node:http";
|
||||
import { spawn, execFileSync } from "node:child_process";
|
||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync, accessSync, existsSync, constants } from "node:fs";
|
||||
import { readFileSync, readdirSync, accessSync, existsSync, constants } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
@@ -41,8 +41,48 @@ const _pkg = JSON.parse(readFileSync(join(__dirname, "package.json"), "utf8"));
|
||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
||||
|
||||
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||
// Priority: CLAUDE_BIN env > well-known paths > which lookup
|
||||
// Fail-fast if not found — never start with an unresolvable binary.
|
||||
// Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
|
||||
// installs > which lookup. Fail-fast if not found — never start with an
|
||||
// unresolvable binary.
|
||||
function _listVersionDirs(parent) {
|
||||
try { return readdirSync(parent); } catch { return []; }
|
||||
}
|
||||
function _collectNodeManagerCandidates(home) {
|
||||
if (!home) return [];
|
||||
const out = [];
|
||||
|
||||
// nvm: $HOME/.nvm/versions/node/<version>/bin/claude
|
||||
const nvmRoot = join(home, ".nvm/versions/node");
|
||||
for (const v of _listVersionDirs(nvmRoot)) {
|
||||
out.push(join(nvmRoot, v, "bin/claude"));
|
||||
}
|
||||
// nvm default alias: resolve $HOME/.nvm/aliases/default if it points to a version
|
||||
try {
|
||||
const aliasFile = join(home, ".nvm/aliases/default");
|
||||
const aliasVer = readFileSync(aliasFile, "utf8").trim();
|
||||
if (aliasVer) {
|
||||
const direct = join(nvmRoot, aliasVer, "bin/claude");
|
||||
if (!out.includes(direct)) out.unshift(direct);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// fnm: $HOME/.fnm/node-versions/<version>/installation/bin/claude
|
||||
const fnmRoot = join(home, ".fnm/node-versions");
|
||||
for (const v of _listVersionDirs(fnmRoot)) {
|
||||
out.push(join(fnmRoot, v, "installation/bin/claude"));
|
||||
}
|
||||
|
||||
// asdf: $HOME/.asdf/installs/nodejs/<version>/bin/claude
|
||||
const asdfRoot = join(home, ".asdf/installs/nodejs");
|
||||
for (const v of _listVersionDirs(asdfRoot)) {
|
||||
out.push(join(asdfRoot, v, "bin/claude"));
|
||||
}
|
||||
|
||||
// npm prefix-relocated: $HOME/.npm-global/bin/claude
|
||||
out.push(join(home, ".npm-global/bin/claude"));
|
||||
|
||||
return out;
|
||||
}
|
||||
function resolveClaude() {
|
||||
if (process.env.CLAUDE_BIN) {
|
||||
try {
|
||||
@@ -54,11 +94,13 @@ function resolveClaude() {
|
||||
}
|
||||
}
|
||||
|
||||
const home = process.env.HOME || "";
|
||||
const candidates = [
|
||||
"/opt/homebrew/bin/claude",
|
||||
"/usr/local/bin/claude",
|
||||
"/usr/bin/claude",
|
||||
join(process.env.HOME || "", ".local/bin/claude"),
|
||||
join(home, ".local/bin/claude"),
|
||||
..._collectNodeManagerCandidates(home),
|
||||
];
|
||||
for (const p of candidates) {
|
||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
||||
@@ -72,6 +114,8 @@ function resolveClaude() {
|
||||
console.error(
|
||||
"FATAL: claude binary not found.\n" +
|
||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\n" +
|
||||
" Hint: if you use nvm/fnm/asdf, set CLAUDE_BIN to the absolute path\n" +
|
||||
" shown by `which claude` in your interactive shell.\n" +
|
||||
" Checked: " + candidates.join(", ")
|
||||
);
|
||||
process.exit(1);
|
||||
@@ -1572,14 +1616,45 @@ const server = createServer(async (req, res) => {
|
||||
}
|
||||
|
||||
if (req.url?.startsWith("/api/usage") && req.method === "GET") {
|
||||
if (!isAdmin) return jsonResponse(res, 403, { error: "Admin access required" });
|
||||
// Least-privilege scope rules (security audit follow-up):
|
||||
// - non-admin authenticated key → only own rows
|
||||
// - anonymous (PROXY_ANONYMOUS_KEY) → only "anonymous" rows; ?all=true ignored
|
||||
// - admin without ?all=true → only own ("admin") rows
|
||||
// - admin with ?all=true → full byKey/recent (legacy behavior); audited
|
||||
// Authenticated callers are required (anyone reaching here passed the auth gate above);
|
||||
// remote+no-auth requests would have been rejected before this point.
|
||||
const url = new URL(req.url, `http://${BIND_ADDRESS}:${PORT}`);
|
||||
const since = url.searchParams.get("since");
|
||||
const until = url.searchParams.get("until");
|
||||
const wantAll = url.searchParams.get("all") === "true";
|
||||
const callerName = req._authKeyName;
|
||||
|
||||
// Anonymous callers may never opt into all-keys view, even if they pass ?all=true.
|
||||
const isAnonCaller = callerName === "anonymous";
|
||||
const fullScope = isAdmin && wantAll && !isAnonCaller;
|
||||
|
||||
// scopeName === null when fullScope is true (no filter); otherwise the key_name to filter by.
|
||||
const scopeName = fullScope ? null : callerName;
|
||||
|
||||
if (fullScope) {
|
||||
logEvent("info", "admin_usage_full_scope", { caller: callerName, ip: req.socket.remoteAddress || null });
|
||||
}
|
||||
|
||||
const byKeyAll = getUsageByKey({ since, until });
|
||||
const recentAll = getRecentUsage(Math.min(parseInt(url.searchParams.get("limit") || "50", 10), 500));
|
||||
const timeline = getUsageTimeline({
|
||||
keyName: scopeName || undefined,
|
||||
hours: Math.min(parseInt(url.searchParams.get("hours") || "24", 10), 720),
|
||||
});
|
||||
|
||||
const byKey = scopeName ? byKeyAll.filter((row) => row.key_name === scopeName) : byKeyAll;
|
||||
const recent = scopeName ? recentAll.filter((row) => row.key_name === scopeName) : recentAll;
|
||||
|
||||
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)),
|
||||
byKey,
|
||||
timeline,
|
||||
recent,
|
||||
scope: { self: scopeName, all: fullScope },
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -39,6 +39,29 @@ 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");
|
||||
|
||||
// ── Service-env injection: CLAUDE_BIN, OCP_ADMIN_KEY, PROXY_ANONYMOUS_KEY ──
|
||||
// These are read from the user's shell env at install time and written into
|
||||
// the service unit (plist / systemd) so the daemon picks them up on boot.
|
||||
|
||||
// CLAUDE_BIN — detect at install time; omit if not found (server.mjs fallback)
|
||||
let CLAUDE_BIN_INJECT = null;
|
||||
if (process.env.CLAUDE_BIN) {
|
||||
CLAUDE_BIN_INJECT = process.env.CLAUDE_BIN;
|
||||
} else {
|
||||
try {
|
||||
const detected = execSync("which claude 2>/dev/null", { encoding: "utf-8" }).trim();
|
||||
if (detected && existsSync(detected)) {
|
||||
CLAUDE_BIN_INJECT = detected;
|
||||
}
|
||||
} catch { /* which not available or claude not on PATH — omit */ }
|
||||
}
|
||||
|
||||
// OCP_ADMIN_KEY — omit entirely when empty/unset; don't write empty string
|
||||
const OCP_ADMIN_KEY_INJECT = process.env.OCP_ADMIN_KEY || null;
|
||||
|
||||
// PROXY_ANONYMOUS_KEY — same pattern
|
||||
const PROXY_ANON_KEY_INJECT = process.env.PROXY_ANONYMOUS_KEY || null;
|
||||
|
||||
// ── Models: derived from models.json (single source of truth) ──────────
|
||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||
|
||||
@@ -286,6 +309,29 @@ banner.push(`╚═════════════════════
|
||||
console.log("\n" + banner.join("\n") + "\n");
|
||||
|
||||
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
||||
|
||||
// Log service-env injection plan (shown in both dry-run and live mode)
|
||||
console.log("\n🔧 Service unit env vars to inject:\n");
|
||||
if (CLAUDE_BIN_INJECT) {
|
||||
log(`CLAUDE_BIN: ${CLAUDE_BIN_INJECT}`);
|
||||
} else {
|
||||
log(`CLAUDE_BIN: (not found — server.mjs will auto-detect at runtime)`);
|
||||
}
|
||||
if (OCP_ADMIN_KEY_INJECT) {
|
||||
log(`OCP_ADMIN_KEY: injected (length: ${OCP_ADMIN_KEY_INJECT.length})`);
|
||||
} else {
|
||||
log(`OCP_ADMIN_KEY: (unset — admin endpoints disabled)`);
|
||||
}
|
||||
if (PROXY_ANON_KEY_INJECT) {
|
||||
log(`PROXY_ANONYMOUS_KEY: injected (set)`);
|
||||
} else {
|
||||
log(`PROXY_ANONYMOUS_KEY: (unset — anonymous access disabled)`);
|
||||
}
|
||||
|
||||
if (DRY_RUN) {
|
||||
console.log("\n [dry-run] would write service unit with above env vars\n");
|
||||
}
|
||||
|
||||
if (!DRY_RUN) {
|
||||
console.log("\n🔄 Installing auto-start on login...\n");
|
||||
|
||||
@@ -358,7 +404,13 @@ if (!DRY_RUN) {
|
||||
<key>CLAUDE_BIND</key>
|
||||
<string>${BIND_ADDRESS}</string>
|
||||
<key>CLAUDE_AUTH_MODE</key>
|
||||
<string>${AUTH_MODE_CONFIG}</string>
|
||||
<string>${AUTH_MODE_CONFIG}</string>${CLAUDE_BIN_INJECT ? `
|
||||
<key>CLAUDE_BIN</key>
|
||||
<string>${CLAUDE_BIN_INJECT}</string>` : ""}${OCP_ADMIN_KEY_INJECT ? `
|
||||
<key>OCP_ADMIN_KEY</key>
|
||||
<string>${OCP_ADMIN_KEY_INJECT}</string>` : ""}${PROXY_ANON_KEY_INJECT ? `
|
||||
<key>PROXY_ANONYMOUS_KEY</key>
|
||||
<string>${PROXY_ANON_KEY_INJECT}</string>` : ""}
|
||||
</dict>
|
||||
<key>RunAtLoad</key>
|
||||
<true/>
|
||||
@@ -396,7 +448,7 @@ After=network.target
|
||||
ExecStart=${nodeBin} ${serverPath}
|
||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}
|
||||
Environment=CLAUDE_AUTH_MODE=${AUTH_MODE_CONFIG}${CLAUDE_BIN_INJECT ? `\nEnvironment=CLAUDE_BIN=${CLAUDE_BIN_INJECT}` : ""}${OCP_ADMIN_KEY_INJECT ? `\nEnvironment=OCP_ADMIN_KEY=${OCP_ADMIN_KEY_INJECT}` : ""}${PROXY_ANON_KEY_INJECT ? `\nEnvironment=PROXY_ANONYMOUS_KEY=${PROXY_ANON_KEY_INJECT}` : ""}
|
||||
Restart=always
|
||||
RestartSec=5
|
||||
StandardOutput=append:${logPath}
|
||||
|
||||
Reference in New Issue
Block a user