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 | |
|---|---|---|---|
|
|
91e1f0d18e | ||
|
|
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).*
|
*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.
|
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
|
1. Verify Claude CLI is installed and authenticated
|
||||||
2. Start the proxy on port 3456
|
2. Start the proxy on port 3456
|
||||||
3. Install auto-start (launchd on macOS, systemd on Linux)
|
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:
|
**Single-machine use** — just set your IDE to use the proxy:
|
||||||
```bash
|
```bash
|
||||||
@@ -395,11 +398,15 @@ In `multi` mode, the admin can designate a single well-known "anonymous" key tha
|
|||||||
|
|
||||||
**Enable**:
|
**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
|
```bash
|
||||||
export PROXY_ANONYMOUS_KEY=ocp_public_anon # or any string of your choice
|
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.
|
**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.
|
**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.
|
||||||
|
|||||||
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"
|
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 args for multi-key mode: reads from OCP_ADMIN_KEY env or ~/.ocp/admin-key file
|
||||||
_AUTH_HEADER=""
|
# Using a bash array preserves word boundaries — no eval needed.
|
||||||
|
_AUTH_ARGS=()
|
||||||
if [[ -n "${OCP_ADMIN_KEY:-}" ]]; then
|
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
|
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
|
fi
|
||||||
|
|
||||||
# Wrapper: curl with optional auth
|
# Wrapper: curl with optional auth
|
||||||
_curl() {
|
_curl() {
|
||||||
if [[ -n "$_AUTH_HEADER" ]]; then
|
curl "${_AUTH_ARGS[@]}" "$@"
|
||||||
eval curl "$_AUTH_HEADER" "$@"
|
|
||||||
else
|
|
||||||
curl "$@"
|
|
||||||
fi
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
_json() { python3 -m json.tool 2>/dev/null || cat; }
|
||||||
|
|||||||
+12
-2
@@ -582,11 +582,19 @@ print(k if k else '')
|
|||||||
if [[ "${SHELL:-}" == */fish ]]; then
|
if [[ "${SHELL:-}" == */fish ]]; then
|
||||||
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
echo " Note: fish shell detected. Writing to ~/.bashrc — add to fish config manually."
|
||||||
rc_files+=("$HOME/.bashrc")
|
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
|
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/.bashrc" || "${SHELL:-}" == */bash ]] && rc_files+=("$HOME/.bashrc")
|
||||||
[[ -f "$HOME/.zshrc" || "${SHELL:-}" == */zsh ]] && rc_files+=("$HOME/.zshrc")
|
[[ -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")
|
[[ ${#rc_files[@]} -eq 0 ]] && rc_files+=("$HOME/.${SHELL##*/}rc")
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -705,7 +713,9 @@ PYEOF
|
|||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo " Done. Reload your shell to apply:"
|
echo " Done. Reload your shell to apply:"
|
||||||
|
for rc_file in "${rc_files[@]}"; do
|
||||||
echo " source $rc_file"
|
echo " source $rc_file"
|
||||||
|
done
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
+88
-26
@@ -30,7 +30,7 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { spawn, execFileSync } from "node:child_process";
|
import { spawn, execFileSync } from "node:child_process";
|
||||||
import { randomUUID, timingSafeEqual } from "node:crypto";
|
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 { fileURLToPath } from "node:url";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { homedir } from "node:os";
|
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"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf8"));
|
||||||
|
|
||||||
// ── Resolve claude binary ───────────────────────────────────────────────
|
// ── Resolve claude binary ───────────────────────────────────────────────
|
||||||
// Priority: CLAUDE_BIN env > well-known paths > which lookup
|
// Priority: CLAUDE_BIN env > well-known paths > nvm/fnm/asdf user-local
|
||||||
// Fail-fast if not found — never start with an unresolvable binary.
|
// 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() {
|
function resolveClaude() {
|
||||||
if (process.env.CLAUDE_BIN) {
|
if (process.env.CLAUDE_BIN) {
|
||||||
try {
|
try {
|
||||||
@@ -54,11 +94,13 @@ function resolveClaude() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const home = process.env.HOME || "";
|
||||||
const candidates = [
|
const candidates = [
|
||||||
"/opt/homebrew/bin/claude",
|
"/opt/homebrew/bin/claude",
|
||||||
"/usr/local/bin/claude",
|
"/usr/local/bin/claude",
|
||||||
"/usr/bin/claude",
|
"/usr/bin/claude",
|
||||||
join(process.env.HOME || "", ".local/bin/claude"),
|
join(home, ".local/bin/claude"),
|
||||||
|
..._collectNodeManagerCandidates(home),
|
||||||
];
|
];
|
||||||
for (const p of candidates) {
|
for (const p of candidates) {
|
||||||
try { accessSync(p, constants.X_OK); console.warn(`[init] CLAUDE_BIN not set, resolved to ${p}`); return p; } catch {}
|
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(
|
console.error(
|
||||||
"FATAL: claude binary not found.\n" +
|
"FATAL: claude binary not found.\n" +
|
||||||
" Set CLAUDE_BIN=/path/to/claude or ensure claude is in PATH.\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(", ")
|
" Checked: " + candidates.join(", ")
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -158,25 +202,36 @@ const MODEL_MAP = Object.fromEntries([
|
|||||||
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
const MODELS = modelsConfig.models.map(m => ({ id: m.id, name: m.displayName }));
|
||||||
|
|
||||||
// ── Session management ──────────────────────────────────────────────────
|
// ── Session management ──────────────────────────────────────────────────
|
||||||
// Maps conversation IDs (from caller) to Claude CLI session UUIDs.
|
// Maps namespaced session keys to Claude CLI session UUIDs.
|
||||||
|
// Key format: "${keyName}|${conversationId}" — prevents cross-key collision
|
||||||
|
// when two callers (different API keys or anon + authenticated) use the same
|
||||||
|
// session_id string. Anonymous callers use "anon"; admin uses "admin".
|
||||||
// Enables --resume for multi-turn conversations, reducing token waste.
|
// Enables --resume for multi-turn conversations, reducing token waste.
|
||||||
const sessions = new Map(); // conversationId → { uuid, messageCount, lastUsed, model }
|
const sessions = new Map(); // `${keyName}|${conversationId}` → { uuid, messageCount, lastUsed, model }
|
||||||
|
|
||||||
|
// Build the namespaced key used for all sessions Map operations.
|
||||||
|
// Returns null when conversationId is falsy (one-off requests bypass session tracking).
|
||||||
|
function _sessionKey(conversationId, keyName) {
|
||||||
|
return conversationId ? `${keyName || "anon"}|${conversationId}` : null;
|
||||||
|
}
|
||||||
|
|
||||||
const sessionCleanupInterval = setInterval(() => {
|
const sessionCleanupInterval = setInterval(() => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
const idleMs = now - s.lastUsed;
|
const idleMs = now - s.lastUsed;
|
||||||
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
const ageMs = s.firstSeen ? now - s.firstSeen : null;
|
||||||
|
// id is "${keyName}|${conversationId}"; strip prefix for log output
|
||||||
|
const convIdShort = id.includes("|") ? id.slice(id.indexOf("|") + 1, id.indexOf("|") + 13) : id.slice(0, 12);
|
||||||
if (idleMs > SESSION_TTL) {
|
if (idleMs > SESSION_TTL) {
|
||||||
sessions.delete(id);
|
sessions.delete(id);
|
||||||
console.log(`[session] expired ${id.slice(0, 12)}... (idle ${Math.round(idleMs / 60000)}m)`);
|
console.log(`[session] expired ${convIdShort}... (idle ${Math.round(idleMs / 60000)}m)`);
|
||||||
logEvent("info", "session_expired", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
logEvent("info", "session_expired", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||||
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
} else if (ageMs !== null && ageMs > 4 * SESSION_TTL) {
|
||||||
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
// #42 evidence-gathering: a session whose firstSeen is more than 4× TTL old
|
||||||
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
|
// but whose lastUsed keeps getting bumped (never idle long enough to expire)
|
||||||
// is the suspected bug. Log without action so the pattern can be confirmed
|
// is the suspected bug. Log without action so the pattern can be confirmed
|
||||||
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
// in /logs. Do NOT enforce an absolute age cap here speculatively.
|
||||||
logEvent("warn", "session_long_lived", { conversationId: id.slice(0, 12) + "...", idleMs, ageMs });
|
logEvent("warn", "session_long_lived", { conversationId: convIdShort + "...", idleMs, ageMs });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 60000);
|
}, 60000);
|
||||||
@@ -383,7 +438,7 @@ function getModelTier(cliModel) {
|
|||||||
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
// ── Spawn claude CLI (shared setup) ─────────────────────────────────────
|
||||||
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
// Resolves session logic, builds CLI args, spawns the process, and sets up
|
||||||
// timeouts. Returns context object or throws synchronously.
|
// timeouts. Returns context object or throws synchronously.
|
||||||
function spawnClaudeProcess(model, messages, conversationId) {
|
function spawnClaudeProcess(model, messages, conversationId, keyName) {
|
||||||
if (stats.activeRequests >= MAX_CONCURRENT) {
|
if (stats.activeRequests >= MAX_CONCURRENT) {
|
||||||
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
throw new Error(`concurrency limit reached (${stats.activeRequests}/${MAX_CONCURRENT})`);
|
||||||
}
|
}
|
||||||
@@ -399,8 +454,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
let prompt;
|
let prompt;
|
||||||
|
|
||||||
// ── Session logic ──
|
// ── Session logic ──
|
||||||
if (conversationId && sessions.has(conversationId)) {
|
// sessionKey namespaces the Map key by keyName to prevent cross-caller collision
|
||||||
const session = sessions.get(conversationId);
|
// when two callers with different API keys share the same conversationId string.
|
||||||
|
const sessionKey = _sessionKey(conversationId, keyName);
|
||||||
|
if (sessionKey && sessions.has(sessionKey)) {
|
||||||
|
const session = sessions.get(sessionKey);
|
||||||
session.lastUsed = Date.now();
|
session.lastUsed = Date.now();
|
||||||
sessionInfo = { uuid: session.uuid, resume: true };
|
sessionInfo = { uuid: session.uuid, resume: true };
|
||||||
stats.sessionHits++;
|
stats.sessionHits++;
|
||||||
@@ -411,17 +469,17 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
: "";
|
: "";
|
||||||
session.messageCount = messages.length;
|
session.messageCount = messages.length;
|
||||||
|
|
||||||
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
console.log(`[session] resume conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${session.uuid.slice(0, 8)}... msgs=${messages.length} prompt_chars=${prompt.length}`);
|
||||||
|
|
||||||
} else if (conversationId) {
|
} else if (sessionKey) {
|
||||||
const uuid = randomUUID();
|
const uuid = randomUUID();
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
sessions.set(conversationId, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
sessions.set(sessionKey, { uuid, messageCount: messages.length, firstSeen: now, lastUsed: now, model: cliModel });
|
||||||
sessionInfo = { uuid, resume: false };
|
sessionInfo = { uuid, resume: false };
|
||||||
stats.sessionMisses++;
|
stats.sessionMisses++;
|
||||||
prompt = messagesToPrompt(messages);
|
prompt = messagesToPrompt(messages);
|
||||||
|
|
||||||
console.log(`[session] new conv=${conversationId.slice(0, 12)}... uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
console.log(`[session] new conv=${conversationId.slice(0, 12)}... key=${keyName || "anon"} uuid=${uuid.slice(0, 8)}... msgs=${messages.length}`);
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
stats.oneOffRequests++;
|
stats.oneOffRequests++;
|
||||||
@@ -466,11 +524,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
proc.once("exit", cleanup);
|
proc.once("exit", cleanup);
|
||||||
|
|
||||||
function handleSessionFailure() {
|
function handleSessionFailure() {
|
||||||
if (sessionInfo?.resume && conversationId) {
|
if (sessionInfo?.resume && sessionKey) {
|
||||||
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
console.warn(`[session] resume failed for ${conversationId.slice(0, 12)}..., removing stale session`);
|
||||||
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
logEvent("warn", "session_failure", { mode: "resume", conversationId: conversationId.slice(0, 12) + "...", action: "deleted" });
|
||||||
sessions.delete(conversationId);
|
sessions.delete(sessionKey);
|
||||||
} else if (sessionInfo && !sessionInfo.resume && conversationId) {
|
} else if (sessionInfo && !sessionInfo.resume && sessionKey) {
|
||||||
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
// #41 evidence-gathering: session-create failures currently leave a stale entry
|
||||||
// in the sessions map. Log without action so the staleness pattern can be
|
// in the sessions map. Log without action so the staleness pattern can be
|
||||||
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
// confirmed in /logs before any code change. Do NOT delete here speculatively.
|
||||||
@@ -513,11 +571,11 @@ function spawnClaudeProcess(model, messages, conversationId) {
|
|||||||
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
// On-demand spawning: each request spawns a fresh `claude -p` process.
|
||||||
// No pool = no crash loops, no stale workers, no degraded states.
|
// No pool = no crash loops, no stale workers, no degraded states.
|
||||||
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
// Stdin is written immediately so there's no 3s stdin timeout issue.
|
||||||
function callClaude(model, messages, conversationId) {
|
function callClaude(model, messages, conversationId, keyName) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return reject(err);
|
return reject(err);
|
||||||
}
|
}
|
||||||
@@ -597,7 +655,7 @@ function callClaudeStreaming(model, messages, conversationId, res, authInfo = {}
|
|||||||
|
|
||||||
let ctx;
|
let ctx;
|
||||||
try {
|
try {
|
||||||
ctx = spawnClaudeProcess(model, messages, conversationId);
|
ctx = spawnClaudeProcess(model, messages, conversationId, authInfo.keyName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
return jsonResponse(res, 500, { error: { message: err.message, type: "proxy_error" } });
|
||||||
}
|
}
|
||||||
@@ -1280,7 +1338,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
// will re-read the freshly-populated cache entry here rather than spawning.
|
// will re-read the freshly-populated cache entry here rather than spawning.
|
||||||
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
const recheck = getCachedResponse(req._cacheHash, CACHE_TTL);
|
||||||
if (recheck) return recheck.response;
|
if (recheck) return recheck.response;
|
||||||
const c = await callClaude(model, messages, conversationId);
|
const c = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||||
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
try { setCachedResponse(req._cacheHash, model, c); } catch (e) { logEvent("error", "cache_write_failed", { error: e.message }); }
|
||||||
return c;
|
return c;
|
||||||
});
|
});
|
||||||
@@ -1302,7 +1360,7 @@ async function handleChatCompletions(req, res) {
|
|||||||
|
|
||||||
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
// Fallback: cache disabled (CACHE_TTL=0) or no _cacheHash — original path untouched.
|
||||||
try {
|
try {
|
||||||
const content = await callClaude(model, messages, conversationId);
|
const content = await callClaude(model, messages, conversationId, req._authKeyName);
|
||||||
const id = `chatcmpl-${randomUUID()}`;
|
const id = `chatcmpl-${randomUUID()}`;
|
||||||
completionResponse(res, id, model, content);
|
completionResponse(res, id, model, content);
|
||||||
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 }); }
|
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 }); }
|
||||||
@@ -1436,8 +1494,10 @@ const server = createServer(async (req, res) => {
|
|||||||
const uptimeMs = Date.now() - START_TIME;
|
const uptimeMs = Date.now() - START_TIME;
|
||||||
const sessionList = [];
|
const sessionList = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
sessionList.push({
|
sessionList.push({
|
||||||
id: id.slice(0, 12) + "...",
|
id: convId.slice(0, 12) + "...",
|
||||||
model: s.model,
|
model: s.model,
|
||||||
messages: s.messageCount,
|
messages: s.messageCount,
|
||||||
idleMs: Date.now() - s.lastUsed,
|
idleMs: Date.now() - s.lastUsed,
|
||||||
@@ -1482,7 +1542,9 @@ const server = createServer(async (req, res) => {
|
|||||||
if (req.url === "/sessions" && req.method === "GET") {
|
if (req.url === "/sessions" && req.method === "GET") {
|
||||||
const list = [];
|
const list = [];
|
||||||
for (const [id, s] of sessions) {
|
for (const [id, s] of sessions) {
|
||||||
list.push({ id, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
// id is "${keyName}|${conversationId}"; expose only the public-facing conversationId
|
||||||
|
const convId = id.includes("|") ? id.slice(id.indexOf("|") + 1) : id;
|
||||||
|
list.push({ id: convId, uuid: s.uuid, model: s.model, messages: s.messageCount, lastUsed: new Date(s.lastUsed).toISOString() });
|
||||||
}
|
}
|
||||||
return jsonResponse(res, 200, { sessions: list });
|
return jsonResponse(res, 200, { sessions: list });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,29 @@ const PROVIDER_NAME = opt("provider-name", "claude-local");
|
|||||||
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
const BIND_ADDRESS = opt("bind", "127.0.0.1");
|
||||||
const AUTH_MODE_CONFIG = opt("auth-mode", "none");
|
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) ──────────
|
// ── Models: derived from models.json (single source of truth) ──────────
|
||||||
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
const modelsConfig = JSON.parse(readFileSync(join(__dirname, "models.json"), "utf-8"));
|
||||||
|
|
||||||
@@ -286,6 +309,29 @@ banner.push(`╚═════════════════════
|
|||||||
console.log("\n" + banner.join("\n") + "\n");
|
console.log("\n" + banner.join("\n") + "\n");
|
||||||
|
|
||||||
// ── Step 7: Install auto-start on boot ──────────────────────────────────
|
// ── 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) {
|
if (!DRY_RUN) {
|
||||||
console.log("\n🔄 Installing auto-start on login...\n");
|
console.log("\n🔄 Installing auto-start on login...\n");
|
||||||
|
|
||||||
@@ -358,7 +404,13 @@ if (!DRY_RUN) {
|
|||||||
<key>CLAUDE_BIND</key>
|
<key>CLAUDE_BIND</key>
|
||||||
<string>${BIND_ADDRESS}</string>
|
<string>${BIND_ADDRESS}</string>
|
||||||
<key>CLAUDE_AUTH_MODE</key>
|
<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>
|
</dict>
|
||||||
<key>RunAtLoad</key>
|
<key>RunAtLoad</key>
|
||||||
<true/>
|
<true/>
|
||||||
@@ -396,7 +448,7 @@ After=network.target
|
|||||||
ExecStart=${nodeBin} ${serverPath}
|
ExecStart=${nodeBin} ${serverPath}
|
||||||
Environment=CLAUDE_PROXY_PORT=${PORT}
|
Environment=CLAUDE_PROXY_PORT=${PORT}
|
||||||
Environment=CLAUDE_BIND=${BIND_ADDRESS}
|
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
|
Restart=always
|
||||||
RestartSec=5
|
RestartSec=5
|
||||||
StandardOutput=append:${logPath}
|
StandardOutput=append:${logPath}
|
||||||
|
|||||||
Reference in New Issue
Block a user