Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e93a9fb
fix: update .env.example to a currently-live Nebius model ID
Apr 30, 2026
dc1e9d1
fix: differentiate model-not-found from auth/rate errors in /test-con…
Apr 30, 2026
fce486e
feat: add --selftest flag to start_proxy.py
Apr 30, 2026
b17994e
docs: document the reasoning-model gotcha in README
Apr 30, 2026
068ea2f
feat: add macOS launchd agent sample for autostart
Apr 30, 2026
27b69a1
feat: add start/stop/status subcommands to start_proxy.py
Apr 30, 2026
78f12aa
fix: drop OBSERVABILITY_DB_PATH from .env.example to unbreak host ins…
Apr 30, 2026
1244cea
Merge pull request #9 from opencolin/fix-observability-default-path
KiranChilledOut Apr 30, 2026
8532537
feat: add install.sh with prereq checks, model validation, and smoke …
Apr 30, 2026
e7c1151
docs: spell out manual ANTHROPIC_BASE_URL / ANTHROPIC_API_KEY wiring …
Apr 30, 2026
4ea7f0c
Merge pull request #10 from opencolin/add-install-script
KiranChilledOut Apr 30, 2026
b5123d6
Merge pull request #11 from opencolin/update-env-example-models
KiranChilledOut Apr 30, 2026
be8e321
Merge pull request #12 from opencolin/improve-test-connection-errors
KiranChilledOut Apr 30, 2026
77043ca
Merge pull request #13 from opencolin/add-selftest-flag
KiranChilledOut Apr 30, 2026
4918f22
Merge pull request #15 from opencolin/document-reasoning-models
KiranChilledOut Apr 30, 2026
04fbaaf
Merge pull request #16 from opencolin/add-launchd-sample
KiranChilledOut Apr 30, 2026
bed67e9
Merge branch 'main' into add-daemon-cli
KiranChilledOut Apr 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"}}'
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,9 @@ __pypackages__/
celerybeat-schedule
celerybeat.pid

# claude-code-proxy daemon state
.proxy.pid

# SageMath parsed files
*.sage.py

Expand Down
42 changes: 41 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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`.
Expand Down
49 changes: 49 additions & 0 deletions contrib/claude-code-proxy.plist.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
launchd agent that keeps claude-code-proxy running on macOS.

Install:
1. Replace REPO_ROOT below with the absolute path to your clone, e.g.
/Users/you/src/claude-code-proxy
2. cp contrib/claude-code-proxy.plist.example \
~/Library/LaunchAgents/com.user.claude-code-proxy.plist
3. launchctl load ~/Library/LaunchAgents/com.user.claude-code-proxy.plist

Stop / unload:
launchctl unload ~/Library/LaunchAgents/com.user.claude-code-proxy.plist

Logs:
tail -f /tmp/claude-code-proxy.log

This is a user-level LaunchAgent (runs at login, dies at logout). Use
/Library/LaunchDaemons + sudo if you need a system-wide variant.
-->
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.user.claude-code-proxy</string>

<key>ProgramArguments</key>
<array>
<string>REPO_ROOT/.venv/bin/python</string>
<string>REPO_ROOT/start_proxy.py</string>
</array>

<key>WorkingDirectory</key>
<string>REPO_ROOT</string>

<key>RunAtLoad</key>
<true/>

<key>KeepAlive</key>
<true/>

<key>StandardOutPath</key>
<string>/tmp/claude-code-proxy.log</string>

<key>StandardErrorPath</key>
<string>/tmp/claude-code-proxy.log</string>
</dict>
</plist>
163 changes: 163 additions & 0 deletions install.sh
Original file line number Diff line number Diff line change
@@ -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 <<MSG

To use the proxy, two things need to be running:

1) The proxy itself, in another terminal:
cd $REPO_ROOT && .venv/bin/python start_proxy.py

2) Claude Code wired up to talk to the proxy. Add to your shell rc
(~/.zshrc or ~/.bashrc):

export ANTHROPIC_BASE_URL=http://localhost:${PORT}
export ANTHROPIC_API_KEY=claude-local

Then open a new shell. Or run as a one-off:
ANTHROPIC_BASE_URL=http://localhost:${PORT} ANTHROPIC_API_KEY=claude-local claude
MSG
35 changes: 29 additions & 6 deletions src/api/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -407,18 +407,41 @@ async def test_connection():

except Exception as e:
logger.error(f"API connectivity test failed: {e}")
msg = str(e)
msg_l = msg.lower()

if "404" in msg or "not found" in msg_l or "does not exist" in msg_l:
suggestions = [
f"The configured model '{config.small_model}' may not be available on this provider — "
f"verify against GET {config.openai_base_url.rstrip('/')}/models",
"Check BIG_MODEL, MIDDLE_MODEL, SMALL_MODEL, and VISION_MODEL in your .env",
"Token-factory providers like Nebius rotate model availability",
]
elif "401" in msg or "403" in msg or "unauthorized" in msg_l or "forbidden" in msg_l:
suggestions = [
"Check your OPENAI_API_KEY is valid",
"Verify your API key has the necessary permissions",
]
elif "429" in msg or "rate" in msg_l:
suggestions = [
"Check if you have reached rate limits",
"Wait and retry, or contact your provider about quota",
]
else:
suggestions = [
"Check your OPENAI_API_KEY is valid",
"Verify your API key has the necessary permissions",
"Check if you have reached rate limits",
]

return JSONResponse(
status_code=503,
content={
"status": "failed",
"error_type": "API Error",
"message": str(e),
"message": msg,
"timestamp": datetime.now().isoformat(),
"suggestions": [
"Check your OPENAI_API_KEY is valid",
"Verify your API key has the necessary permissions",
"Check if you have reached rate limits",
],
"suggestions": suggestions,
},
)

Expand Down
Loading
Loading