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")