diff --git a/.env.example b/.env.example index 7dc5ed02..57dbe2b4 100644 --- a/.env.example +++ b/.env.example @@ -4,9 +4,12 @@ IGNORE_CLIENT_API_KEY=true OPENAI_BASE_URL=https://api.tokenfactory.nebius.com/v1 -BIG_MODEL=zai-org/GLM-4.7-FP8 -MIDDLE_MODEL=zai-org/GLM-4.7-FP8 -SMALL_MODEL=zai-org/GLM-4.7-FP8 +# Nebius rotates model availability — verify IDs against +# GET https://api.tokenfactory.nebius.com/v1/models +# before committing. install.sh does this automatically. +BIG_MODEL=moonshotai/Kimi-K2.5 +MIDDLE_MODEL=moonshotai/Kimi-K2.5 +SMALL_MODEL=moonshotai/Kimi-K2.5 VISION_MODEL=Qwen/Qwen2.5-VL-72B-Instruct # Optional: explicit context limits (tokens) for safer max_tokens auto-capping BIG_MODEL_CONTEXT_LIMIT=204800 @@ -26,9 +29,12 @@ MAX_RETRIES=2 # Observability dashboard OBSERVABILITY_ENABLED=true -# Docker Compose bind-mounts ./data to /app/data, so history stays in the repo root. -OBSERVABILITY_DB_PATH=/app/data/observability.sqlite3 +# Non-Docker installs fall back to "observability.sqlite3" in the current +# working directory (see src/core/config.py). Docker users get the +# /app/data/... path via docker-compose.yml's environment override and the +# matching ./data:/app/data bind mount, so this line is intentionally +# omitted to avoid breaking host installs that don't have /app. OBSERVABILITY_QUEUE_SIZE=1000 # Keep tool argument storage off unless you explicitly need deeper debugging. OBSERVABILITY_STORE_TOOL_ARGS=false -MODEL_PRICES_JSON='{"zai-org/GLM-4.7-FP8":{"input_per_1m":0.30,"output_per_1m":1.20,"advertised_tok_s":36.8,"currency":"USD"},"Qwen/Qwen2.5-VL-72B-Instruct":{"input_per_1m":0.30,"output_per_1m":1.20,"advertised_tok_s":36.8,"currency":"USD"}}' +MODEL_PRICES_JSON='{"moonshotai/Kimi-K2.5":{"input_per_1m":0.30,"output_per_1m":1.20,"advertised_tok_s":36.8,"currency":"USD"},"Qwen/Qwen2.5-VL-72B-Instruct":{"input_per_1m":0.30,"output_per_1m":1.20,"advertised_tok_s":36.8,"currency":"USD"}}' diff --git a/.gitignore b/.gitignore index ec762518..e1d27794 100644 --- a/.gitignore +++ b/.gitignore @@ -120,6 +120,9 @@ __pypackages__/ celerybeat-schedule celerybeat.pid +# claude-code-proxy daemon state +.proxy.pid + # SageMath parsed files *.sage.py diff --git a/README.md b/README.md index bcac5b9e..827d6136 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,31 @@ VISION_MODEL="Qwen/Qwen2.5-VL-72B-Instruct" STRIP_IMAGE_CONTEXT="true" ``` +#### Reasoning models + +Several Nebius-hosted models emit *hidden* reasoning tokens before producing +visible output. These tokens count against `max_tokens`, so very small budgets +can return empty content. Known reasoning-style models on Nebius: + +- `moonshotai/Kimi-K2.5` +- `deepseek-ai/DeepSeek-V3.2` +- `zai-org/GLM-5` +- `Qwen/Qwen3-Next-80B-A3B-Thinking` +- `Qwen/Qwen3-235B-A22B-Thinking-2507-fast` + +Implication: keep `MAX_TOKENS_LIMIT` and per-request `max_tokens` generous +(>=4096 is recommended; 16k+ is safer for agentic tool-use loops). If a +reasoning model returns empty text with a non-zero `output_tokens` count, the +budget was exhausted by reasoning before any visible output was produced — +raise the limit and retry. + +Verify model availability and pick alternatives at: + +```bash +curl -s https://api.tokenfactory.nebius.com/v1/models \ + -H "Authorization: Bearer $OPENAI_API_KEY" | jq '.data[].id' +``` + ### Run ```bash @@ -101,8 +126,23 @@ uv run claude-code-proxy-nebius ### Use with Claude Code +Claude Code talks to the proxy via two environment variables: +`ANTHROPIC_BASE_URL` (where to send requests) and `ANTHROPIC_API_KEY` +(by default, the proxy ignores the client key and accepts any non-empty +string). + +To wire this up permanently, add the following to your shell rc +(`~/.zshrc` or `~/.bashrc`), then open a new terminal: + +```bash +export ANTHROPIC_BASE_URL=http://localhost:8083 +export ANTHROPIC_API_KEY=claude-local +``` + +Or run as a one-off, prefixing the env vars on the command line: + ```bash -ANTHROPIC_BASE_URL="http://localhost:8083" ANTHROPIC_API_KEY="any-value" claude +ANTHROPIC_BASE_URL=http://localhost:8083 ANTHROPIC_API_KEY=claude-local claude ``` If `IGNORE_CLIENT_API_KEY=false`, the client key must match `ANTHROPIC_API_KEY`. diff --git a/contrib/claude-code-proxy.plist.example b/contrib/claude-code-proxy.plist.example new file mode 100644 index 00000000..908309ae --- /dev/null +++ b/contrib/claude-code-proxy.plist.example @@ -0,0 +1,49 @@ + + + + + + Label + com.user.claude-code-proxy + + ProgramArguments + + REPO_ROOT/.venv/bin/python + REPO_ROOT/start_proxy.py + + + WorkingDirectory + REPO_ROOT + + RunAtLoad + + + KeepAlive + + + StandardOutPath + /tmp/claude-code-proxy.log + + StandardErrorPath + /tmp/claude-code-proxy.log + + diff --git a/install.sh b/install.sh new file mode 100755 index 00000000..796394c5 --- /dev/null +++ b/install.sh @@ -0,0 +1,163 @@ +#!/usr/bin/env bash +# install.sh — bootstrap claude-code-proxy against a live Nebius account. +# +# Implements lessons learned from real installs: +# - pip <22 in a fresh venv can't do editable installs → upgrade pip first +# - the bundled .env.example pins models that Nebius has retired → validate +# configured model IDs against /v1/models before declaring success +# - "server bound to :8083" is not the same as "request succeeds" → smoke +# test /test-connection and exit non-zero if it fails +# - prompting for the API key with `read -rs` keeps it out of shell history + +set -euo pipefail + +red() { printf '\033[31m%s\033[0m\n' "$*" >&2; } +green() { printf '\033[32m%s\033[0m\n' "$*"; } +yellow() { printf '\033[33m%s\033[0m\n' "$*"; } +info() { printf '\033[36m==>\033[0m %s\n' "$*"; } + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$REPO_ROOT" + +info "Checking prerequisites" +command -v python3 >/dev/null || { red "python3 not found"; exit 1; } +command -v curl >/dev/null || { red "curl not found"; exit 1; } + +PY_VER=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else 1)' \ + || { red "Python >= 3.9 required, found $PY_VER"; exit 1; } +green " python3 $PY_VER" + +if [[ ! -d .venv ]]; then + info "Creating .venv" + python3 -m venv .venv +fi + +info "Upgrading pip in .venv (fresh venvs ship pip <22 which fails on pyproject editable installs)" +.venv/bin/python -m pip install --quiet --upgrade pip + +info "Installing dependencies from requirements.txt" +.venv/bin/pip install --quiet -r requirements.txt + +if [[ -f .env ]]; then + yellow ".env already exists — leaving it alone. Edit it manually to change keys or models." +else + info "Creating .env from .env.example" + cp .env.example .env + printf "Paste your Nebius API key (input hidden, press Enter when done): " + read -rs NEBIUS_KEY + echo + [[ -n "$NEBIUS_KEY" ]] || { red "No key provided"; exit 1; } + + NEBIUS_KEY="$NEBIUS_KEY" .venv/bin/python <<'PY' +import os, pathlib, re +key = os.environ["NEBIUS_KEY"] +p = pathlib.Path(".env") +text = p.read_text() +text = re.sub( + r'^OPENAI_API_KEY=.*$', + f'OPENAI_API_KEY={key}', + text, + count=1, + flags=re.MULTILINE, +) +p.write_text(text) +PY + chmod 600 .env + green " wrote .env (mode 600)" +fi + +info "Validating configured models against Nebius /v1/models" +.venv/bin/python <<'PY' +import json, pathlib, sys, urllib.request + +env = {} +for line in pathlib.Path(".env").read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + env[k.strip()] = v.strip().strip('"').strip("'").split("#", 1)[0].strip() + +key = env.get("OPENAI_API_KEY", "") +base = env.get("OPENAI_BASE_URL", "https://api.tokenfactory.nebius.com/v1").rstrip("/") +if not key or "YOUR_NEBIUS_API_KEY_HERE" in key: + print(" no usable API key in .env — fill it in and re-run", file=sys.stderr) + sys.exit(1) + +req = urllib.request.Request(f"{base}/models", headers={"Authorization": f"Bearer {key}"}) +try: + with urllib.request.urlopen(req, timeout=15) as r: + available = {m["id"] for m in json.load(r).get("data", [])} +except Exception as e: + print(f" could not list models from {base}: {e}", file=sys.stderr) + sys.exit(1) + +configured = {k: env[k] for k in ("BIG_MODEL", "MIDDLE_MODEL", "SMALL_MODEL", "VISION_MODEL") if env.get(k)} +missing = {k: v for k, v in configured.items() if v not in available} +if missing: + print(" some configured models are not available on Nebius:", file=sys.stderr) + for k, v in missing.items(): + print(f" {k}={v}", file=sys.stderr) + print(" examples of currently-available IDs:", file=sys.stderr) + for m in sorted(available)[:15]: + print(f" {m}", file=sys.stderr) + print(" edit .env and re-run install.sh.", file=sys.stderr) + sys.exit(1) + +print(f" all {len(configured)} configured models are live") +PY +green " models validated" + +info "Smoke-testing the proxy (boot, /test-connection, shut down)" +LOG=$(mktemp -t claude-proxy-smoke.XXXXXX.log) +.venv/bin/python start_proxy.py >"$LOG" 2>&1 & +PROXY_PID=$! +cleanup() { kill "$PROXY_PID" 2>/dev/null || true; } +trap cleanup EXIT + +PORT="$(grep -E '^PORT=' .env 2>/dev/null | tail -n1 | cut -d= -f2 | tr -d '"' || echo 8083)" +PORT="${PORT:-8083}" + +for _ in $(seq 1 30); do + if curl -sf -m 2 "http://localhost:${PORT}/health" >/dev/null 2>&1; then break; fi + sleep 0.5 +done + +if ! curl -sf -m 2 "http://localhost:${PORT}/health" >/dev/null 2>&1; then + red " proxy did not bind to :${PORT}; first 40 lines of log:" + head -40 "$LOG" >&2 + exit 1 +fi + +RESULT="$(curl -s -m 30 "http://localhost:${PORT}/test-connection")" +STATUS="$(printf '%s' "$RESULT" | .venv/bin/python -c 'import json,sys; print(json.load(sys.stdin).get("status",""))' 2>/dev/null || true)" + +cleanup +trap - EXIT + +if [[ "$STATUS" != "success" ]]; then + red " /test-connection did not return success:" + printf '%s\n' "$RESULT" >&2 + exit 1 +fi +green " /test-connection: success" + +green "" +green "Install complete." +cat < 1 and sys.argv[1] in ("start", "stop", "status"): + cmd = sys.argv[1] + if cmd == "start": + sys.exit(daemon_start()) + if cmd == "stop": + sys.exit(daemon_stop()) + if cmd == "status": + sys.exit(daemon_status()) + if len(sys.argv) > 1 and sys.argv[1] == "--help": print("Claude-to-OpenAI API Proxy v1.0.0") print("") - print("Usage: python start_proxy.py") + print("Usage: python start_proxy.py [start|stop|status|--help]") + print("") + print(" (no args) Run the proxy in the foreground (Ctrl-C to stop).") + print(" start Start the proxy as a detached background process.") + print(" PID is recorded in .proxy.pid; logs go to proxy.log.") + print(" stop Stop a background-running proxy via .proxy.pid.") + print(" status Print whether a background proxy is currently running.") + print("Usage: python start_proxy.py [--help|--selftest]") + print("") + print(" --selftest Hit /test-connection in-process and exit 0 if it") + print(" succeeds, non-zero otherwise. Intended for CI and") + print(" install scripts.") print("") print("Required environment variables:") print(" OPENAI_API_KEY - Your provider API key") @@ -63,6 +175,31 @@ def main(): print(f" Requests with images -> {config.vision_model}") sys.exit(0) + if len(sys.argv) > 1 and sys.argv[1] == "--selftest": + # In-process smoke test: invoke /test-connection via Starlette's + # TestClient (no uvicorn, no port binding) and exit 0/1 based on + # the result. We deliberately don't enter TestClient as a context + # manager so FastAPI lifespan handlers (which would open the + # observability sqlite database) do not fire. + import json + + from fastapi.testclient import TestClient + + client = TestClient(app) + response = client.get("/test-connection") + try: + result = response.json() + except ValueError: + print( + f"selftest: non-JSON response (status {response.status_code}): " + f"{response.text}", + file=sys.stderr, + ) + sys.exit(2) + json.dump(result, sys.stdout, indent=2) + sys.stdout.write("\n") + sys.exit(0 if result.get("status") == "success" else 1) + # Configuration summary print("🚀 Claude-to-OpenAI API Proxy v1.0.0") print(f"✅ Configuration loaded successfully")