diff --git a/.env.docker.example b/.env.docker.example index d919708..b536ae3 100644 --- a/.env.docker.example +++ b/.env.docker.example @@ -8,9 +8,6 @@ # Port for the proxy server (default: 4181) PROXY_PORT=4181 -# Container name for the proxy service -PROXY_CONTAINER_NAME=cc-proxy - # ============================================================================= # API KEYS (REQUIRED) # ============================================================================= @@ -31,9 +28,6 @@ POSTGRES_PASSWORD=postgres DB_PORT=5432 DB_HOST=cc-db -# Container name for the database service -DB_CONTAINER_NAME=cc-db - # Database Connection Pool Settings DB_SSL=false DB_MAX_CONNECTIONS=10 @@ -54,9 +48,6 @@ ADMINER_PORT=8080 # Adminer version (default: latest) ADMINER_VERSION=latest -# Container name for Adminer service -ADMINER_CONTAINER_NAME=cc-adminer - # Adminer design theme (options: nette, pepa-linha, pappu687, etc.) ADMINER_DESIGN=nette diff --git a/com.claude.sync-credentials.plist b/com.claude.sync-credentials.plist new file mode 100644 index 0000000..4ffbb7d --- /dev/null +++ b/com.claude.sync-credentials.plist @@ -0,0 +1,21 @@ + + + + + Label + com.claude.sync-credentials + ProgramArguments + + /bin/bash + /Users/raulneiva/conductor/workspaces/claude-code-proxy/worcester/scripts/sync-credentials.sh + + StartInterval + 300 + RunAtLoad + + StandardOutPath + /tmp/claude-sync-credentials.log + StandardErrorPath + /tmp/claude-sync-credentials.err + + diff --git a/docker-compose.yml b/docker-compose.yml index ea53f91..dd0dec4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,10 @@ services: # Claude Code Proxy cc-proxy: + container_name: cc-proxy build: context: . dockerfile: Dockerfile - container_name: ${PROXY_CONTAINER_NAME:-cc-proxy} ports: - "${HOST_PROXY_PORT:-4181}:4181" env_file: @@ -14,12 +14,11 @@ services: - API_STREAMING_TIMEOUT_MS=${API_STREAMING_TIMEOUT_MS:-18000000} - API_MAX_RETRIES=${API_MAX_RETRIES:-5} - API_RETRY_DELAY_MS=${API_RETRY_DELAY_MS:-2000} - - LOG_LEVEL=${LOG_LEVEL:-info} - NODE_ENV=${NODE_ENV:-production} - CLAUDE_CREDENTIALS_PATH=/app/credentials.json - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-postgres}@${DB_HOST:-cc-db}:5432/${POSTGRES_DB:-claude_proxy} volumes: - - ~/.claude/.credentials.json:/app/credentials.json:rw + - ~/.claude/claude-credentials.json:/app/credentials.json:rw healthcheck: test: [ @@ -42,8 +41,8 @@ services: # PostgreSQL Database cc-db: + container_name: cc-db image: postgres:${POSTGRES_VERSION:-15}-alpine - container_name: ${DB_CONTAINER_NAME:-cc-db} environment: - POSTGRES_DB=${POSTGRES_DB:-claude_proxy} - POSTGRES_USER=${POSTGRES_USER:-postgres} @@ -64,8 +63,8 @@ services: # Adminer (Database Management UI) cc-adminer: + container_name: cc-adminer image: adminer:${ADMINER_VERSION:-latest} - container_name: ${ADMINER_CONTAINER_NAME:-cc-adminer} ports: - "${ADMINER_PORT:-8080}:8080" environment: diff --git a/docs/CLAUDE_SETTINGS_GUIDE.md b/docs/CLAUDE_SETTINGS_GUIDE.md index 5cae066..e610c3d 100644 --- a/docs/CLAUDE_SETTINGS_GUIDE.md +++ b/docs/CLAUDE_SETTINGS_GUIDE.md @@ -80,7 +80,9 @@ If you prefer to configure Claude Code manually, edit `~/.claude/settings.json`: ```json { "env": { - "ANTHROPIC_API_URL": "http://127.0.0.1:4181" + "ANTHROPIC_BASE_URL": "http://127.0.0.1:4181", + "ANTHROPIC_API_URL": "http://127.0.0.1:4181", + "ANTHROPIC_API_KEY": "cc-proxy" } } ``` @@ -91,7 +93,7 @@ Then restart Claude Code. ### Claude Code isn't using the proxy -1. Check that `~/.claude/settings.json` contains the proxy URL +1. Check that `~/.claude/settings.json` contains `ANTHROPIC_BASE_URL`, `ANTHROPIC_API_URL`, and `ANTHROPIC_API_KEY` 2. Restart Claude Code completely (not just the terminal) 3. Verify the proxy is running: `curl http://127.0.0.1:4181/health` diff --git a/docs/CREDENTIAL_SYNC.md b/docs/CREDENTIAL_SYNC.md new file mode 100644 index 0000000..468f5e2 --- /dev/null +++ b/docs/CREDENTIAL_SYNC.md @@ -0,0 +1,100 @@ +# Auto-Sync Claude Credentials + +The proxy needs fresh Claude OAuth credentials to use your subscription (savings plan) instead of API keys. This setup automatically syncs credentials from the macOS Keychain to a file that the proxy container reads. + +## How It Works + +1. **macOS Keychain** stores your Claude OAuth token (service: `Claude Code-credentials`) +2. **sync-credentials.sh** extracts the token and writes to `~/.claude/claude-credentials.json` +3. **LaunchAgent** runs the sync script every 5 minutes automatically +4. **Docker mount** shares the credentials file with the `cc-proxy` container (read-write) + +## One-Time Setup + +Run the setup script to install the LaunchAgent: + +```bash +bash scripts/setup-credential-sync.sh +``` + +This will: +- Copy the LaunchAgent plist to `~/Library/LaunchAgents/` +- Start the background job that syncs every 5 minutes +- Log output to `/tmp/claude-sync-credentials.log` + +## Manual Sync + +To manually refresh credentials (e.g., after CLI login): + +```bash +bash scripts/sync-credentials.sh +``` + +## Monitoring + +Check sync status: + +```bash +# View recent sync logs +tail -f /tmp/claude-sync-credentials.log + +# Check if LaunchAgent is running +launchctl list | grep com.claude + +# View token expiry +cat ~/.claude/claude-credentials.json | python3 -c " +import sys, json, datetime +d = json.load(sys.stdin) +ms = d['claudeAiOauth'].get('expiresAt', 0) +if ms: + dt = datetime.datetime.fromtimestamp(ms / 1000).strftime('%Y-%m-%d %H:%M:%S') + print(f'Token expires: {dt}') +" +``` + +## Troubleshooting + +### LaunchAgent not running + +```bash +# Unload and reload +launchctl unload ~/Library/LaunchAgents/com.claude.sync-credentials.plist +launchctl load ~/Library/LaunchAgents/com.claude.sync-credentials.plist +``` + +### Token still expired after sync + +The CLI auto-refreshes tokens when used directly. Run a CLI command to trigger refresh: + +```bash +claude --version +bash scripts/sync-credentials.sh +podman restart cc-proxy +``` + +### Proxy can't read credentials + +Check the mount is read-write: + +```bash +podman inspect cc-proxy --format '{{range .Mounts}}{{if eq .Destination "/app/credentials.json"}}{{.Mode}}{{end}}{{end}}' +``` + +Should return `rw`. If `ro`, update `docker-compose.yml`: + +```yaml +volumes: + - ~/.claude/claude-credentials.json:/app/credentials.json:rw +``` + +## Docker Integration + +The proxy container mounts the credentials file: + +```yaml +cc-proxy: + volumes: + - ~/.claude/claude-credentials.json:/app/credentials.json:rw +``` + +Changes to the file on the host are immediately visible to the container (no restart needed for token updates, only for initial mount changes). diff --git a/package.json b/package.json index 18f017b..008d29a 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,8 @@ "db:seed": "node dist/database/seed.js", "setup:claude": "node scripts/setup-claude-proxy.js", "setup:docker": "powershell -ExecutionPolicy Bypass -File scripts/setup-docker.ps1", + "settings:update": "node scripts/update-claude-settings.js", + "settings:update:bash": "bash scripts/update-claude-settings.sh", "settings:restore": "node scripts/restore-claude-settings.js", "settings:backup": "node scripts/backup-claude-settings.js", "providers:reset": "curl -s -X POST http://127.0.0.1:4181/providers/reset", @@ -39,16 +41,16 @@ "usage-tracking", "prisma" ], - "author": "Raul Neiva", + "author": "0xPuncker", "license": "MIT", "repository": { "type": "git", "url": "https://github.com/raulneiva/claude-code-proxy.git" }, "bugs": { - "url": "https://github.com/raulneiva/claude-code-proxy/issues" + "url": "https://github.com/0xPuncker/claude-code-proxy/issues" }, - "homepage": "https://github.com/raulneiva/claude-code-proxy#readme", + "homepage": "https://github.com/0xPuncker/claude-code-proxy#readme", "engines": { "node": ">=18.0.0" }, diff --git a/scripts/README.md b/scripts/README.md index d9c2253..208acd4 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -50,6 +50,8 @@ npm run settings:restore 1. **Backup**: Creates `~/.claude/backups/settings.json.backup.TIMESTAMP` 2. **Update**: Modifies `~/.claude/settings.json`: - Sets `ANTHROPIC_BASE_URL` to `http://127.0.0.1:4181` + - Sets `ANTHROPIC_API_URL` to `http://127.0.0.1:4181` for compatibility + - Sets `ANTHROPIC_API_KEY` to a local placeholder (`cc-proxy`) so Claude Code uses the API path - Removes `ANTHROPIC_AUTH_TOKEN` (handled by cc-proxy) 3. **Validate**: Ensures JSON syntax is correct 4. **Report**: Shows exactly what was changed @@ -62,7 +64,9 @@ If you prefer to configure manually, update `~/.claude/settings.json`: { "$schema": "https://json.schemastore.org/claude-code-settings.json", "env": { - "ANTHROPIC_BASE_URL": "http://127.0.0.1:4181" + "ANTHROPIC_BASE_URL": "http://127.0.0.1:4181", + "ANTHROPIC_API_URL": "http://127.0.0.1:4181", + "ANTHROPIC_API_KEY": "cc-proxy" // Remove ANTHROPIC_AUTH_TOKEN if present } } @@ -88,19 +92,19 @@ curl -s http://127.0.0.1:4181/api/logs | jq . **Proxy not responding:** ```bash # Check if proxy is running -docker ps | grep cc-proxy +docker compose ps cc-proxy # Start proxy if needed -docker-compose up -d +docker compose up -d # Check proxy logs -docker logs cc-proxy --tail 50 +docker compose logs --tail 50 cc-proxy ``` **Settings not applied:** ```bash # Verify settings file -cat ~/.claude/settings.json | jq .env.ANTHROPIC_BASE_URL +cat ~/.claude/settings.json | jq .env.ANTHROPIC_API_URL # Restore from backup cp ~/.claude/backups/settings.json.backup. ~/.claude/settings.json @@ -109,10 +113,10 @@ cp ~/.claude/backups/settings.json.backup. ~/.claude/settings.json **Database connection issues:** ```bash # Check database is running -docker ps | grep cc-db +docker compose ps cc-db # Verify database connection -docker exec cc-proxy printenv | grep DB_HOST +docker compose exec cc-proxy printenv | grep DB_HOST ``` ## Additional NPM Scripts @@ -145,7 +149,7 @@ npm run db:seed ## Support For issues or questions: -1. Check proxy logs: `docker logs cc-proxy` +1. Check proxy logs: `docker compose logs cc-proxy` 2. Verify settings: `cat ~/.claude/settings.json | jq .` 3. Test proxy: `curl http://127.0.0.1:4181/health` -4. Check database: `docker logs cc-db` \ No newline at end of file +4. Check database: `docker compose logs cc-db` diff --git a/scripts/setup-claude-proxy.js b/scripts/setup-claude-proxy.js index d7a859e..1084e29 100644 --- a/scripts/setup-claude-proxy.js +++ b/scripts/setup-claude-proxy.js @@ -13,6 +13,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROXY_PORT = process.env.HOST_PROXY_PORT || process.env.PROXY_PORT || '4181'; const PROXY_URL = `http://127.0.0.1:${PROXY_PORT}`; +const PROXY_API_KEY = process.env.CC_PROXY_API_KEY || 'cc-proxy'; // Paths const homeDir = os.homedir(); @@ -66,11 +67,12 @@ function backupSettings() { function isProxyConfigured(settings) { if (!settings) return false; - // Check for various ways the proxy might be configured - if (settings.env?.ANTHROPIC_API_URL?.includes('127.0.0.1:4181')) return true; - if (settings.apiUrl?.includes('127.0.0.1:4181')) return true; + if (settings.env?.ANTHROPIC_API_URL !== PROXY_URL) return false; + if (settings.env?.ANTHROPIC_BASE_URL !== PROXY_URL) return false; + if (!settings.env?.ANTHROPIC_API_KEY) return false; + if (settings.env?.ANTHROPIC_AUTH_TOKEN) return false; - return false; + return true; } /** @@ -87,8 +89,10 @@ function addProxyConfig(settings) { settings.env = {}; } - // Set the proxy URL — ANTHROPIC_BASE_URL is what the Anthropic SDK reads + settings.env.ANTHROPIC_API_URL = PROXY_URL; settings.env.ANTHROPIC_BASE_URL = PROXY_URL; + settings.env.ANTHROPIC_API_KEY = PROXY_API_KEY; + delete settings.env.ANTHROPIC_AUTH_TOKEN; return settings; } diff --git a/scripts/setup-credential-sync.sh b/scripts/setup-credential-sync.sh new file mode 100755 index 0000000..84262b6 --- /dev/null +++ b/scripts/setup-credential-sync.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Install LaunchAgent to auto-sync Claude credentials every 5 minutes + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PLIST_FILE="$PROJECT_ROOT/com.claude.sync-credentials.plist" +LAUNCH_AGENTS_DIR="$HOME/Library/LaunchAgents" +TARGET_PLIST="$LAUNCH_AGENTS_DIR/com.claude.sync-credentials.plist" + +echo "📦 Installing Claude credential sync LaunchAgent..." + +# Ensure LaunchAgents directory exists +mkdir -p "$LAUNCH_AGENTS_DIR" + +# Copy plist to LaunchAgents directory +cp "$PLIST_FILE" "$TARGET_PLIST" + +# Update the script path in the plist to use the absolute path +/usr/bin/sed -i '' "s|/Users/raulneiva/conductor/workspaces/claude-code-proxy/worcester/scripts/sync-credentials.sh|$SCRIPT_DIR/sync-credentials.sh|g" "$TARGET_PLIST" + +# Load the LaunchAgent +/bin/launchctl unload "$TARGET_PLIST" 2>/dev/null || true +/bin/launchctl load "$TARGET_PLIST" + +echo "✅ LaunchAgent installed and started!" +echo "" +echo "Details:" +echo " - Sync interval: Every 5 minutes" +echo " - Log file: /tmp/claude-sync-credentials.log" +echo " - Error log: /tmp/claude-sync-credentials.err" +echo "" +echo "Commands:" +echo " - Start: launchctl load $TARGET_PLIST" +echo " - Stop: launchctl unload $TARGET_PLIST" +echo " - Status: launchctl list | grep com.claude" +echo " - Logs: tail -f /tmp/claude-sync-credentials.log" +echo "" +echo "The proxy container will automatically pick up credential changes." diff --git a/scripts/sync-credentials.sh b/scripts/sync-credentials.sh new file mode 100755 index 0000000..b66d8fa --- /dev/null +++ b/scripts/sync-credentials.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# Reads the Claude Code OAuth token from macOS Keychain and writes it to +# ~/.claude/claude-credentials.json so Docker can mount it as a file. +# +# Run once manually after login, or wire into a LaunchAgent for auto-refresh. +# Usage: bash scripts/sync-credentials.sh [--watch] + +set -euo pipefail + +DEST="${CLAUDE_CREDENTIALS_FILE:-$HOME/.claude/claude-credentials.json}" +KEYCHAIN_SERVICE="Claude Code-credentials" + +extract_and_write() { + local raw + raw=$(security find-generic-password -s "$KEYCHAIN_SERVICE" -w 2>/dev/null) || { + echo "ERROR: Could not read '$KEYCHAIN_SERVICE' from Keychain." >&2 + echo "Make sure you are logged into Claude Code CLI." >&2 + exit 1 + } + + # Validate it has the expected shape before writing + echo "$raw" | python3 -c " +import sys, json +d = json.load(sys.stdin) +assert 'claudeAiOauth' in d, 'missing claudeAiOauth key' +assert d['claudeAiOauth'].get('accessToken'), 'accessToken is empty' +" 2>/dev/null || { + echo "ERROR: Keychain entry does not contain a valid claudeAiOauth token." >&2 + exit 1 + } + + echo "$raw" > "$DEST" + chmod 600 "$DEST" + + local expires_at + expires_at=$(echo "$raw" | python3 -c " +import sys, json, datetime +d = json.load(sys.stdin) +ms = d['claudeAiOauth'].get('expiresAt', 0) +if ms: + dt = datetime.datetime.fromtimestamp(ms / 1000).strftime('%Y-%m-%d %H:%M:%S') + print(dt) +else: + print('unknown') +" 2>/dev/null) + + echo "Credentials synced → $DEST (expires: $expires_at)" +} + +if [[ "${1:-}" == "--watch" ]]; then + echo "Watching for token refresh every 5 minutes..." + while true; do + extract_and_write + sleep 300 + done +else + extract_and_write +fi diff --git a/scripts/update-claude-settings.js b/scripts/update-claude-settings.js index b87ccc8..e27fa9d 100644 --- a/scripts/update-claude-settings.js +++ b/scripts/update-claude-settings.js @@ -15,6 +15,7 @@ const __dirname = path.dirname(__filename); const CLAUDE_SETTINGS = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'settings.json'); const CC_PROXY_PORT = process.env.HOST_PROXY_PORT || process.env.PROXY_PORT || '4181'; const CC_PROXY_URL = `http://127.0.0.1:${CC_PROXY_PORT}`; +const CC_PROXY_API_KEY = process.env.CC_PROXY_API_KEY || 'cc-proxy'; const BACKUP_DIR = path.join(process.env.HOME || process.env.USERPROFILE, '.claude', 'backups'); const TIMESTAMP = new Date().toISOString().replace(/[:.]/g, '-').split('T')[0] + '_' + new Date().toTimeString().split(' ')[0].replace(/:/g, ''); @@ -84,7 +85,9 @@ function main() { const settings = JSON.parse(fs.readFileSync(CLAUDE_SETTINGS, 'utf8')); // Store current values for display + const oldApiUrl = settings.env?.ANTHROPIC_API_URL || 'not set'; const oldBaseUrl = settings.env?.ANTHROPIC_BASE_URL || 'not set'; + const oldApiKey = settings.env?.ANTHROPIC_API_KEY ? 'set' : 'not set'; const oldAuthToken = settings.env?.ANTHROPIC_AUTH_TOKEN || 'not set'; // Update settings @@ -92,9 +95,14 @@ function main() { settings.env = {}; } + // Claude Code 2.1.x routes API-key traffic through ANTHROPIC_BASE_URL. + // ANTHROPIC_API_URL alone can leave subscription/OAuth traffic on Claude's + // first-party path, where local subscription limits fire before cc-proxy. settings.env.ANTHROPIC_BASE_URL = CC_PROXY_URL; + settings.env.ANTHROPIC_API_URL = CC_PROXY_URL; + settings.env.ANTHROPIC_API_KEY = CC_PROXY_API_KEY; - // Remove auth token as it's handled by the proxy + // Remove conflicting auth token; the proxy handles upstream credentials. if (settings.env.ANTHROPIC_AUTH_TOKEN) { delete settings.env.ANTHROPIC_AUTH_TOKEN; } @@ -109,12 +117,16 @@ function main() { // Display changes log(colors.blue, '📊 Configuration changes:'); log(colors.green, 'BEFORE:'); + console.log(` ANTHROPIC_API_URL: ${oldApiUrl}`); console.log(` ANTHROPIC_BASE_URL: ${oldBaseUrl}`); + console.log(` ANTHROPIC_API_KEY: ${oldApiKey}`); console.log(` ANTHROPIC_AUTH_TOKEN: ${oldAuthToken}`); console.log(''); log(colors.green, 'AFTER:'); + console.log(` ANTHROPIC_API_URL: ${CC_PROXY_URL}`); console.log(` ANTHROPIC_BASE_URL: ${CC_PROXY_URL}`); + console.log(` ANTHROPIC_API_KEY: ${CC_PROXY_API_KEY}`); console.log(` ANTHROPIC_AUTH_TOKEN: removed (handled by proxy)`); console.log(''); diff --git a/scripts/update-claude-settings.sh b/scripts/update-claude-settings.sh index 01f32c3..2d2c478 100644 --- a/scripts/update-claude-settings.sh +++ b/scripts/update-claude-settings.sh @@ -13,7 +13,9 @@ NC='\033[0m' # No Color # Configuration CLAUDE_SETTINGS="$HOME/.claude/settings.json" -CC_PROXY_URL="http://127.0.0.1:4181" +CC_PROXY_PORT="${HOST_PROXY_PORT:-${PROXY_PORT:-4181}}" +CC_PROXY_URL="http://127.0.0.1:$CC_PROXY_PORT" +CC_PROXY_API_KEY="${CC_PROXY_API_KEY:-cc-proxy}" BACKUP_DIR="$HOME/.claude/backups" TIMESTAMP=$(date +%Y%m%d_%H%M%S) BACKUP_FILE="$BACKUP_DIR/settings.json.backup.$TIMESTAMP" @@ -66,13 +68,12 @@ echo "" # Update settings echo -e "${YELLOW}⚙️ Updating settings for cc-proxy...${NC}" -jq --arg url "$CC_PROXY_URL" ' - if .env.ANTHROPIC_AUTH_TOKEN then - del(.env.ANTHROPIC_AUTH_TOKEN) | - .env.ANTHROPIC_BASE_URL = $url - else - .env.ANTHROPIC_BASE_URL = $url - end +jq --arg url "$CC_PROXY_URL" --arg key "$CC_PROXY_API_KEY" ' + .env = (.env // {}) | + del(.env.ANTHROPIC_AUTH_TOKEN) | + .env.ANTHROPIC_BASE_URL = $url | + .env.ANTHROPIC_API_URL = $url | + .env.ANTHROPIC_API_KEY = $key ' "$CLAUDE_SETTINGS" > "${CLAUDE_SETTINGS}.tmp" # Validate new settings @@ -89,11 +90,15 @@ echo "" # Display changes echo -e "${BLUE}📊 Configuration changes:${NC}" echo -e "${GREEN}BEFORE:${NC}" +jq -r '.env | " ANTHROPIC_API_URL: \(.ANTHROPIC_API_URL // "not set")"' "$BACKUP_FILE" 2>/dev/null || echo " ANTHROPIC_API_URL: not set" jq -r '.env | " ANTHROPIC_BASE_URL: \(.ANTHROPIC_BASE_URL // "not set")"' "$BACKUP_FILE" 2>/dev/null || echo " ANTHROPIC_BASE_URL: not set" +jq -r '.env | " ANTHROPIC_API_KEY: \(if .ANTHROPIC_API_KEY then "set" else "not set" end)"' "$BACKUP_FILE" 2>/dev/null || echo " ANTHROPIC_API_KEY: not set" jq -r '.env | " ANTHROPIC_AUTH_TOKEN: \(.ANTHROPIC_AUTH_TOKEN // "not set")"' "$BACKUP_FILE" 2>/dev/null || echo " ANTHROPIC_AUTH_TOKEN: not set" echo -e "${GREEN}AFTER:${NC}" +jq -r '.env | " ANTHROPIC_API_URL: \(.ANTHROPIC_API_URL // "not set")"' "$CLAUDE_SETTINGS" 2>/dev/null || echo " ANTHROPIC_API_URL: not set" jq -r '.env | " ANTHROPIC_BASE_URL: \(.ANTHROPIC_BASE_URL // "not set")"' "$CLAUDE_SETTINGS" 2>/dev/null || echo " ANTHROPIC_BASE_URL: not set" +jq -r '.env | " ANTHROPIC_API_KEY: \(.ANTHROPIC_API_KEY // "not set")"' "$CLAUDE_SETTINGS" 2>/dev/null || echo " ANTHROPIC_API_KEY: not set" jq -r '.env | " ANTHROPIC_AUTH_TOKEN: removed (handled by proxy)"' "$CLAUDE_SETTINGS" 2>/dev/null echo "" @@ -126,4 +131,4 @@ echo -e " # Restore backup if needed:" echo -e " cp $BACKUP_FILE $CLAUDE_SETTINGS" echo "" -echo -e "${GREEN}✅ All done! Your Claude Code is now configured to use cc-proxy.${NC}" \ No newline at end of file +echo -e "${GREEN}✅ All done! Your Claude Code is now configured to use cc-proxy.${NC}" diff --git a/src/index.ts b/src/index.ts index fa56dd9..9020f7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,8 @@ import { ProxyConfig, RequestOptions, HttpResponse, LogLevel, RequestMetrics } f import { UsageTracker } from "./database/tracker.js"; import { ProviderHealth } from "./provider-health.js"; +type ProviderErrorType = "rate_limit" | "context_window" | "auth_error" | "other"; + const _pkgPath = path.join(path.dirname(fileURLToPath(import.meta.url)), "../package.json"); const PROXY_VERSION: string = JSON.parse(fs.readFileSync(_pkgPath, "utf-8")).version ?? "0.0.0"; @@ -226,6 +228,7 @@ export class ClaudeCodeProxy { private usageTracker: UsageTracker; private providerHealth: ProviderHealth; private requestCounter = 0; + private subscriptionCooldownUntil = 0; constructor(config: Partial = {}) { this.config = this.mergeConfig(config); @@ -348,7 +351,7 @@ export class ClaudeCodeProxy { reqPath: string, reqMethod: string ): Promise { - if (!this.config.claudeSubscription.enabled) return undefined; + if (!this.canTryClaudeSubscription()) return undefined; const oauthToken = await this.readClaudeOAuthToken(); if (!oauthToken) { @@ -379,10 +382,12 @@ export class ClaudeCodeProxy { } if (subRes.status < 400) { + this.resetClaudeSubscriptionCircuit(); this.logger.ok(`← Claude subscription ${subRes.status}`); return subRes; } + this.recordClaudeSubscriptionFailure(subRes.status, subRes.body); const details = subRes.body.toString().slice(0, 300); const message = `← Claude subscription ❌ ${subRes.status}: ${details} — trying next provider`; if (this.config.fallbackOnCodes.includes(subRes.status)) { @@ -392,6 +397,7 @@ export class ClaudeCodeProxy { } return undefined; } catch (err) { + this.enterClaudeSubscriptionCooldown("network error"); this.logger.error(`← Claude subscription ❌ ${err instanceof Error ? err.message : "Unknown"} — fallback`); return undefined; } @@ -622,6 +628,212 @@ export class ClaudeCodeProxy { } } + /** + * Check if a response body/status represents provider quota or rate limiting. + * Some providers return quota exhaustion as 403/400 with a textual error rather + * than a plain HTTP 429. + */ + private isRateLimitError(status: number, body: Buffer): boolean { + if (status === 429) return true; + + const lower = body.toString().toLowerCase(); + const rateLimitPatterns = [ + "rate limit", + "rate_limit", + "rate-limit", + "too many requests", + "quota exceeded", + "quota_exceeded", + "usage limit", + "credit balance", + "insufficient credits", + "billing", + "overloaded", + "capacity", + ]; + + return rateLimitPatterns.some((pattern) => lower.includes(pattern)); + } + + private classifyProviderError( + status: number, + body: Buffer + ): ProviderErrorType { + // Check body-based classifications first so a 403 with quota/billing + // content is correctly identified as rate_limit rather than auth_error. + if (this.isRateLimitError(status, body)) { + return "rate_limit"; + } + if (this.isContextWindowError(body)) { + return "context_window"; + } + // 401 always means bad/expired credentials. 403 without quota signals + // in the body is also an auth/permissions error — not a transient failure. + if (status === 401 || status === 403) { + return "auth_error"; + } + return "other"; + } + + private inspectInitialStreamingBuffer(buffer: string): { + errorType?: ProviderErrorType; + readyToFlush: boolean; + } { + const normalized = buffer.replace(/\r\n/g, "\n"); + const completeEvents = normalized.split("\n\n").slice(0, -1); + + if (completeEvents.length === 0) { + return { readyToFlush: Buffer.byteLength(buffer) > 65536 }; + } + + for (const eventText of completeEvents) { + const lines = eventText.split("\n"); + const eventName = lines + .find((line) => line.toLowerCase().startsWith("event:")) + ?.slice("event:".length) + .trim() + .toLowerCase(); + const data = lines + .filter((line) => line.toLowerCase().startsWith("data:")) + .map((line) => line.slice("data:".length).trimStart()) + .join("\n") + .trim(); + + if (eventName === "error") { + return { + errorType: this.classifyProviderError(429, Buffer.from(data || eventText)), + readyToFlush: false, + }; + } + + if (data && data !== "[DONE]") { + try { + const json = JSON.parse(data); + if (json?.type === "error" || json?.error) { + return { + errorType: this.classifyProviderError(429, Buffer.from(data)), + readyToFlush: false, + }; + } + } catch { + // Non-JSON data belongs to normal text deltas. + } + } + } + + return { readyToFlush: true }; + } + + private createStreamingSemanticError(errorType: ProviderErrorType, body: string): Error & { + streamingErrorType?: ProviderErrorType; + errorBody?: Buffer; + } { + const err = new Error(`Initial streaming error: ${errorType}`) as Error & { + streamingErrorType?: ProviderErrorType; + errorBody?: Buffer; + }; + err.streamingErrorType = errorType; + err.errorBody = Buffer.from(body); + return err; + } + + private getProviderModel( + requestedModel: string | undefined, + provider: "anthropic" | "zai" | "openrouter" + ): string | undefined { + if (!requestedModel) return requestedModel; + + const modelLower = requestedModel.toLowerCase(); + if (provider === "zai" && modelLower.startsWith("claude-")) { + if (modelLower.includes("haiku")) return "glm-4.5-air"; + return "glm-4"; + } + + if (provider === "anthropic" && modelLower.startsWith("glm-")) { + if (modelLower.includes("-air") || modelLower.includes("-turbo")) { + return "claude-haiku-4-5-20251001"; + } + return "claude-sonnet-4-6"; + } + + if (provider === "openrouter") { + return this.mapModel(requestedModel, "openrouter"); + } + + return requestedModel; + } + + private prepareProviderRequestBody( + reqBody: string, + provider: "anthropic" | "zai" | "openrouter", + requestedModel: string | undefined + ): string { + const providerModel = this.getProviderModel(requestedModel, provider); + if (!providerModel || providerModel === requestedModel) return reqBody; + + try { + const parsed = JSON.parse(reqBody); + parsed.model = providerModel; + this.logger.debug(` model conversion (${provider}): ${requestedModel} → ${providerModel}`); + return JSON.stringify(parsed); + } catch { + return reqBody; + } + } + + private getClaudeSubscriptionState(): { + state: "healthy" | "cooling_down"; + available: boolean; + readyAt?: string; + } { + const cooldownEnd = this.subscriptionCooldownUntil; + if (cooldownEnd > Date.now()) { + return { + state: "cooling_down", + available: false, + readyAt: new Date(cooldownEnd).toISOString(), + }; + } + + return { + state: "healthy", + available: this.config.claudeSubscription.enabled, + }; + } + + private canTryClaudeSubscription(): boolean { + return ( + this.config.claudeSubscription.enabled && + this.subscriptionCooldownUntil <= Date.now() + ); + } + + private resetClaudeSubscriptionCircuit(): void { + this.subscriptionCooldownUntil = 0; + } + + private enterClaudeSubscriptionCooldown(reason: string): void { + const cooldownMs = this.config.circuitBreaker?.cooldownMs || 60000; + this.subscriptionCooldownUntil = Date.now() + cooldownMs; + this.logger.warn( + `Claude subscription circuit open (${reason}); cooling down until ${new Date( + this.subscriptionCooldownUntil + ).toISOString()}` + ); + } + + private recordClaudeSubscriptionFailure(status: number, body: Buffer): void { + const errorType = this.classifyProviderError(status, body); + if (errorType === "rate_limit" || errorType === "context_window" || errorType === "auth_error") { + this.enterClaudeSubscriptionCooldown(errorType); + return; + } + + if (this.config.fallbackOnCodes.includes(status)) { + this.enterClaudeSubscriptionCooldown(`HTTP ${status}`); + } + } + private normalizeOpenRouterResponse(response: HttpResponse): HttpResponse { try { const raw = JSON.parse(response.body.toString()); @@ -955,7 +1167,7 @@ export class ClaudeCodeProxy { reqHeaders: Record, reqPath: string, reqMethod: string - ): Promise<{ response: HttpResponse; errorType?: 'rate_limit' | 'context_window' | 'other' }> { + ): Promise<{ response: HttpResponse; errorType?: ProviderErrorType }> { let config; if (provider === 'anthropic') { config = this.config.anthropic; @@ -988,15 +1200,11 @@ export class ClaudeCodeProxy { } // Detect error types - let errorType: 'rate_limit' | 'context_window' | 'other' | undefined; + let errorType: ProviderErrorType | undefined; if (response.status >= 400) { - if (response.status === 429) { - errorType = 'rate_limit'; - } else if (this.isContextWindowError(response.body)) { - errorType = 'context_window'; + errorType = this.classifyProviderError(response.status, response.body); + if (errorType === "context_window") { this.logger.debug(` Context window error detected for ${provider}`); - } else { - errorType = 'other'; } } @@ -1029,7 +1237,7 @@ export class ClaudeCodeProxy { // Check if we should prefer Claude subscription over Z.AI // (when Anthropic has no API key but subscription is available) - if (selectedProvider === 'zai' && this.config.claudeSubscription.enabled) { + if (selectedProvider === 'zai' && this.canTryClaudeSubscription()) { const oauthToken = await this.readClaudeOAuthToken(); if (oauthToken && !this.config.anthropic.apiKey) { this.logger.info(`Claude subscription available - preferring subscription over Z.AI`); @@ -1097,7 +1305,11 @@ export class ClaudeCodeProxy { const reason = errorType === 'context_window' ? "context window" : errorType === 'rate_limit' ? "rate limit" : + errorType === 'auth_error' ? "auth error" : `HTTP ${response.status}`; + if (cbEnabled && errorType) { + this.providerHealth.recordFailure(primaryProvider, errorType); + } this.logger.warn(`[${requestId}] ← ⚠️ ${primaryProvider.toUpperCase()} ${reason} — trying subscription`); // Try Claude subscription before the other API provider @@ -1112,11 +1324,13 @@ export class ClaudeCodeProxy { let lastFallbackResponse: HttpResponse | undefined; for (const fallbackProvider of fallbackProviders) { - this.logger.info(`[${requestId}] → ${this.formatRequestLog(fallbackProvider, model, false, 'retrying')}`); + const fallbackReqBody = this.prepareProviderRequestBody(reqBody, fallbackProvider, model); + const fallbackModel = this.extractModel(fallbackReqBody) ?? model; + this.logger.info(`[${requestId}] → ${this.formatRequestLog(fallbackProvider, fallbackModel, false, 'retrying')}`); const { response: fallbackRes } = await this.requestProvider( fallbackProvider, - reqBody, + fallbackReqBody, reqHeaders, reqPath, reqMethod @@ -1153,16 +1367,8 @@ export class ClaudeCodeProxy { lastFallbackResponse = fallbackRes; if (cbEnabled) { - const fbErrorType = - fallbackRes.status === 429 - ? "rate_limit" - : this.isContextWindowError(fallbackRes.body) - ? "context_window" - : "other"; - this.providerHealth.recordFailure( - fallbackProvider, - fbErrorType - ); + const fbErrorType = this.classifyProviderError(fallbackRes.status, fallbackRes.body); + this.providerHealth.recordFailure(fallbackProvider, fbErrorType); } this.logger.error( @@ -1204,12 +1410,14 @@ export class ClaudeCodeProxy { let lastFallbackError: unknown; for (const fallbackProvider of fallbackProviders) { - this.logger.info(`[${requestId}] → ${this.formatRequestLog(fallbackProvider, model, false, 'fallback')}`); + const fallbackReqBody = this.prepareProviderRequestBody(reqBody, fallbackProvider, model); + const fallbackModel = this.extractModel(fallbackReqBody) ?? model; + this.logger.info(`[${requestId}] → ${this.formatRequestLog(fallbackProvider, fallbackModel, false, 'fallback')}`); try { const { response: fallbackRes } = await this.requestProvider( fallbackProvider, - reqBody, + fallbackReqBody, reqHeaders, reqPath, reqMethod @@ -1248,16 +1456,8 @@ export class ClaudeCodeProxy { lastFallbackResponse = fallbackRes; if (cbEnabled) { - const fbErrorType = - fallbackRes.status === 429 - ? "rate_limit" - : this.isContextWindowError(fallbackRes.body) - ? "context_window" - : "other"; - this.providerHealth.recordFailure( - fallbackProvider, - fbErrorType - ); + const fbErrorType = this.classifyProviderError(fallbackRes.status, fallbackRes.body); + this.providerHealth.recordFailure(fallbackProvider, fbErrorType); } this.logger.error(`← ${fallbackProvider.toUpperCase()} ${fallbackRes.status}`); @@ -1317,7 +1517,7 @@ export class ClaudeCodeProxy { // Check if we should prefer Claude subscription over Z.AI // (when Anthropic has no API key but subscription is available) - if (selectedProvider === 'zai' && this.config.claudeSubscription.enabled) { + if (selectedProvider === 'zai' && this.canTryClaudeSubscription()) { const oauthToken = await this.readClaudeOAuthToken(); if (oauthToken && !this.config.anthropic.apiKey) { this.logger.info(`[${requestId}] → Claude subscription preferred over Z.AI`); @@ -1353,13 +1553,11 @@ export class ClaudeCodeProxy { } if (subRes.statusCode && subRes.statusCode > 0 && subRes.statusCode < 400) { + this.resetClaudeSubscriptionCircuit(); this.logger.ok(`[${requestId}] ← ✅ Claude subscription (stream) ${subRes.statusCode}`); - if (!clientRes.headersSent) { - clientRes.writeHead(subRes.statusCode, subRes.headers); - headersSent = true; - } const streamingChunks: string[] = []; + let initialBuffer = ""; const idleTimeoutMs = this.config.timeout?.idleMs || 30000; let idleTimer: ReturnType | null = null; @@ -1380,7 +1578,27 @@ export class ClaudeCodeProxy { subRes.destroy(new Error(`Stream idle timeout after ${idleTimeoutMs}ms`)); }); try { - streamingChunks.push(chunk.toString()); + const chunkStr = chunk.toString(); + streamingChunks.push(chunkStr); + + if (!headersSent && !clientRes.headersSent) { + initialBuffer += chunkStr; + const inspection = this.inspectInitialStreamingBuffer(initialBuffer); + if (inspection.errorType) { + const streamErr = this.createStreamingSemanticError(inspection.errorType, initialBuffer); + subRes.destroy(streamErr); + rejectStream(streamErr); + return; + } + if (!inspection.readyToFlush) return; + + clientRes.writeHead(subRes.statusCode || 200, subRes.headers); + headersSent = true; + clientRes.write(initialBuffer); + initialBuffer = ""; + return; + } + clientRes.write(chunk); } catch (writeErr) { this.logger.debug(`Error writing to client: ${writeErr instanceof Error ? writeErr.message : 'Unknown'}`); @@ -1390,6 +1608,11 @@ export class ClaudeCodeProxy { subRes.on('end', async () => { if (idleTimer) clearTimeout(idleTimer); try { + if (!headersSent && !clientRes.headersSent) { + clientRes.writeHead(subRes.statusCode || 200, subRes.headers); + headersSent = true; + if (initialBuffer) clientRes.write(initialBuffer); + } clientRes.end(); await this.trackStreamingRequestMetrics(reqMethod, reqPath, startTime, subRes.statusCode || 200, true, 'anthropic', model, streamingChunks); resolveStream(); @@ -1408,7 +1631,24 @@ export class ClaudeCodeProxy { this.logger.ok(`[${requestId}] ← ✅ Stream completed via subscription`); return; // Successfully used subscription, exit } + + const subStatus = subRes.statusCode || 0; + if (subStatus >= 400) { + const subErrorBody = await this.readIncomingBody(subRes); + this.recordClaudeSubscriptionFailure(subStatus, subErrorBody); + this.logger.warn( + `← Claude subscription ❌ ${subStatus}: ${subErrorBody + .toString() + .slice(0, 300)} — falling back to Z.AI` + ); + } } catch (err) { + const semanticError = err as Error & { streamingErrorType?: ProviderErrorType; errorBody?: Buffer }; + if (semanticError.streamingErrorType) { + this.recordClaudeSubscriptionFailure(429, semanticError.errorBody || Buffer.from("")); + } else { + this.enterClaudeSubscriptionCooldown("stream error"); + } this.logger.warn(`← Claude subscription stream failed: ${err instanceof Error ? err.message : 'Unknown'} — falling back to Z.AI`); // Continue with Z.AI } @@ -1429,15 +1669,17 @@ export class ClaudeCodeProxy { config = this.config.openrouter; } + const providerReqBody = this.prepareProviderRequestBody(reqBody, provider, model); + const effectiveModel = this.extractModel(providerReqBody) ?? model; const headers = this.buildProviderHeaders(provider, reqHeaders); const normalizedPath = this.normalizeProviderPath(provider, reqPath); - const body = this.cleanBody(reqBody, provider); + const body = this.cleanBody(providerReqBody, provider); const timeoutMs = this.config.timeout?.streamingMs || 600000; // 10 minutes default for streaming headers["content-length"] = Buffer.byteLength(body).toString(); - this.logger.info(`[${requestId}] → ${this.formatRequestLog(provider, model, true, 'streaming')}`); - this.logger.debug(`[${requestId}] ${this.debugRequestDetails(reqBody)}`); + this.logger.info(`[${requestId}] → ${this.formatRequestLog(provider, effectiveModel, true, 'streaming')}`); + this.logger.debug(`[${requestId}] ${this.debugRequestDetails(providerReqBody)}`); try { const res = await new Promise((resolve, reject) => { @@ -1475,14 +1717,7 @@ export class ClaudeCodeProxy { } const errorBody = Buffer.concat(chunks); - let errorType: "rate_limit" | "context_window" | "other" | undefined; - if (statusCode === 429) { - errorType = 'rate_limit'; - } else if (this.isContextWindowError(errorBody)) { - errorType = 'context_window'; - } else { - errorType = 'other'; - } + const errorType = this.classifyProviderError(statusCode, errorBody); if (cbEnabled && errorType) { this.providerHealth.recordFailure(provider, errorType); @@ -1499,11 +1734,8 @@ export class ClaudeCodeProxy { // Success - pipe response to client this.logger.ok(`[${requestId}] ← ✅ ${statusCode} | streaming`); - if (!clientRes.headersSent) { - clientRes.writeHead(statusCode, res.headers); - headersSent = true; - } + let initialBuffer = ""; const idleTimeoutMs = this.config.timeout?.idleMs || 30000; // 30s per-chunk idle default let idleTimer: ReturnType | null = null; @@ -1524,7 +1756,27 @@ export class ClaudeCodeProxy { res.destroy(new Error(`Stream idle timeout after ${idleTimeoutMs}ms`)); }); try { - streamingChunks.push(chunk.toString()); + const chunkStr = chunk.toString(); + streamingChunks.push(chunkStr); + + if (!headersSent && !clientRes.headersSent) { + initialBuffer += chunkStr; + const inspection = this.inspectInitialStreamingBuffer(initialBuffer); + if (inspection.errorType) { + const streamErr = this.createStreamingSemanticError(inspection.errorType, initialBuffer); + res.destroy(streamErr); + rejectStream(streamErr); + return; + } + if (!inspection.readyToFlush) return; + + clientRes.writeHead(statusCode, res.headers); + headersSent = true; + clientRes.write(initialBuffer); + initialBuffer = ""; + return; + } + clientRes.write(chunk); } catch (writeErr) { this.logger.debug(`Error writing to client: ${writeErr instanceof Error ? writeErr.message : 'Unknown'}`); @@ -1534,6 +1786,11 @@ export class ClaudeCodeProxy { res.on('end', async () => { if (idleTimer) clearTimeout(idleTimer); try { + if (!headersSent && !clientRes.headersSent) { + clientRes.writeHead(statusCode, res.headers); + headersSent = true; + if (initialBuffer) clientRes.write(initialBuffer); + } clientRes.end(); const latency = Date.now() - startTime; if (cbEnabled) { @@ -1561,8 +1818,10 @@ export class ClaudeCodeProxy { return { success: true, statusCode }; } catch (err) { const errorMsg = err instanceof Error ? err.message : "Unknown"; + const semanticError = err as Error & { streamingErrorType?: ProviderErrorType }; + const failureType = semanticError.streamingErrorType || "other"; if (cbEnabled) { - this.providerHealth.recordFailure(provider, 'other'); + this.providerHealth.recordFailure(provider, failureType); } // If headers were already sent, we can't retry - just log and return success to prevent fallback @@ -1572,7 +1831,7 @@ export class ClaudeCodeProxy { } this.logger.error(`← ❌ ${provider.toUpperCase()} stream error: ${errorMsg}`); - return { success: false, statusCode: 0, errorType: 'network' }; + return { success: false, statusCode: 0, errorType: failureType }; } }; @@ -1592,7 +1851,7 @@ export class ClaudeCodeProxy { const cleanedPath = this.cleanPath(reqPath); const cleanedBody = this.cleanBody(reqBody, "subscription"); - if (this.config.claudeSubscription.enabled) { + if (this.canTryClaudeSubscription()) { const oauthToken = await this.readClaudeOAuthToken(); if (oauthToken) { const trySubscriptionStream = (token: string) => @@ -1625,31 +1884,62 @@ export class ClaudeCodeProxy { if (subRes) { const subStatus = subRes.statusCode || 0; if (subStatus > 0 && subStatus < 400) { + this.resetClaudeSubscriptionCircuit(); this.logger.ok(`← Claude subscription (stream) ${subRes.statusCode}`); - if (!clientRes.headersSent) { - clientRes.writeHead(subRes.statusCode!, subRes.headers); - headersSent = true; - } - subRes.on('data', (chunk) => { - try { - streamingChunks.push(chunk.toString()); - clientRes.write(chunk); - } catch (writeErr) { - this.logger.debug(`Error writing subscription response: ${writeErr instanceof Error ? writeErr.message : 'Unknown'}`); - } - }); - subRes.on('end', async () => { - try { - clientRes.end(); - await this.trackStreamingRequestMetrics(reqMethod, reqPath, startTime, subStatus, true, 'anthropic', model, streamingChunks); - } catch (endErr) { - this.logger.debug(`Error in subscription end handler: ${endErr instanceof Error ? endErr.message : 'Unknown'}`); - } + let initialBuffer = ""; + + await new Promise((resolveStream, rejectStream) => { + subRes.on('data', (chunk) => { + try { + const chunkStr = chunk.toString(); + streamingChunks.push(chunkStr); + + if (!headersSent && !clientRes.headersSent) { + initialBuffer += chunkStr; + const inspection = this.inspectInitialStreamingBuffer(initialBuffer); + if (inspection.errorType) { + const streamErr = this.createStreamingSemanticError(inspection.errorType, initialBuffer); + subRes.destroy(streamErr); + rejectStream(streamErr); + return; + } + if (!inspection.readyToFlush) return; + + clientRes.writeHead(subRes.statusCode || 200, subRes.headers); + headersSent = true; + clientRes.write(initialBuffer); + initialBuffer = ""; + return; + } + + clientRes.write(chunk); + } catch (writeErr) { + this.logger.debug(`Error writing subscription response: ${writeErr instanceof Error ? writeErr.message : 'Unknown'}`); + } + }); + subRes.on('end', async () => { + try { + if (!headersSent && !clientRes.headersSent) { + clientRes.writeHead(subRes.statusCode || 200, subRes.headers); + headersSent = true; + if (initialBuffer) clientRes.write(initialBuffer); + } + clientRes.end(); + await this.trackStreamingRequestMetrics(reqMethod, reqPath, startTime, subStatus, true, 'anthropic', model, streamingChunks); + resolveStream(); + } catch (endErr) { + this.logger.debug(`Error in subscription end handler: ${endErr instanceof Error ? endErr.message : 'Unknown'}`); + resolveStream(); + } + }); + subRes.on('error', rejectStream); }); + return; } const subErrorBody = await this.readIncomingBody(subRes); + this.recordClaudeSubscriptionFailure(subStatus, subErrorBody); const subMessage = `← Claude subscription ❌ ${subStatus}: ${subErrorBody .toString() .slice(0, 300)} — trying other provider`; @@ -1660,6 +1950,12 @@ export class ClaudeCodeProxy { } } } catch (err) { + const semanticError = err as Error & { streamingErrorType?: ProviderErrorType; errorBody?: Buffer }; + if (semanticError.streamingErrorType) { + this.recordClaudeSubscriptionFailure(429, semanticError.errorBody || Buffer.from("")); + } else { + this.enterClaudeSubscriptionCooldown("stream error"); + } this.logger.error(`← Claude subscription ❌ stream error: ${err instanceof Error ? err.message : "Unknown"}`); } } else { @@ -1718,7 +2014,12 @@ export class ClaudeCodeProxy { tokenUsage: tokenUsage || undefined, }; - await this.usageTracker.trackRequest(method, path, metrics); + const trackedId = await this.usageTracker.trackRequest(method, path, metrics); + if (trackedId !== null) { + this.logger.debug(`tracked: id=${trackedId} provider=${provider} status=${statusCode} duration=${duration}ms`); + } else { + this.logger.warn(`tracking write returned null — DB unreachable or insert failed (provider=${provider} status=${statusCode})`); + } } /** @@ -1757,7 +2058,12 @@ export class ClaudeCodeProxy { tokenUsage: tokenUsage || undefined, }; - await this.usageTracker.trackRequest(method, path, metrics); + const trackedId = await this.usageTracker.trackRequest(method, path, metrics); + if (trackedId !== null) { + this.logger.debug(`tracked: id=${trackedId} provider=${provider} status=${statusCode} duration=${duration}ms`); + } else { + this.logger.warn(`tracking write returned null — DB unreachable or insert failed (provider=${provider} status=${statusCode})`); + } } /** @@ -1815,7 +2121,8 @@ export class ClaudeCodeProxy { openrouter: { state: providerStatus.find(p => p.provider === 'openrouter')?.state || 'unknown', available: providerStatus.find(p => p.provider === 'openrouter')?.available || false - } + }, + subscription: this.getClaudeSubscriptionState() } : undefined })); return; @@ -1838,6 +2145,7 @@ export class ClaudeCodeProxy { clientRes.end(JSON.stringify({ enabled: true, bestProvider: this.providerHealth.getBestProvider(), + subscription: this.getClaudeSubscriptionState(), providers: status.map(s => ({ provider: s.provider, state: s.state, @@ -1867,8 +2175,9 @@ export class ClaudeCodeProxy { clientRes.end(JSON.stringify({ status: "ok", message: "All providers have been reset to healthy state", - providers: ["anthropic", "zai", "openrouter"] + providers: ["anthropic", "zai", "openrouter", "subscription"] })); + this.resetClaudeSubscriptionCircuit(); return; } @@ -2320,6 +2629,9 @@ es.onerror = () => { public async start(): Promise { const port = this.config.port; + this.logger.info(`Logger initialized at level=${this.config.logLevel}`); + this.logger.info(`Usage tracking enabled=${this.usageTracker.isTrackingEnabled()}`); + // Initialize database if tracking is enabled if (this.usageTracker.isTrackingEnabled()) { try { @@ -2329,10 +2641,13 @@ es.onerror = () => { this.logger.error("Failed to initialize database tracking"); this.logger.error("Continuing without database tracking..."); } + } else { + this.logger.warn("Usage tracking disabled — DATABASE_URL not set, /usage will return empty"); } this.server.listen(port, "0.0.0.0", () => { this.printStartupBanner(port); + this.logger.ok(`Server listening on 0.0.0.0:${port} (level=${this.config.logLevel})`); }); } diff --git a/src/provider-health.ts b/src/provider-health.ts index 8633ffb..23a9554 100644 --- a/src/provider-health.ts +++ b/src/provider-health.ts @@ -631,7 +631,7 @@ export class ProviderHealth { */ recordFailure( provider: "anthropic" | "zai" | "openrouter", - errorType: "rate_limit" | "context_window" | "other" + errorType: "rate_limit" | "context_window" | "auth_error" | "other" ): void { const metrics = this.metrics.get(provider)!; const config = this.providers.get(provider)!; @@ -648,9 +648,10 @@ export class ProviderHealth { metrics.contextWindowErrors++; } - // IMMEDIATE COOLDOWN for context window or rate limit errors - // This prevents cascading failures when many concurrent requests are sent - if (errorType === "context_window" || errorType === "rate_limit") { + // IMMEDIATE COOLDOWN for context window, rate limit, or auth errors. + // Auth errors (401/403) mean the API key is invalid — retrying immediately + // is pointless and just produces noise until the key is fixed. + if (errorType === "context_window" || errorType === "rate_limit" || errorType === "auth_error") { this.enterCooldown(provider); return; } @@ -712,6 +713,7 @@ export class ProviderHealth { } } }, delay); + timer.unref?.(); this.healthCheckTimers.set(provider, timer); } diff --git a/tests/proxy.test.js b/tests/proxy.test.js index 4ac024a..200326b 100644 --- a/tests/proxy.test.js +++ b/tests/proxy.test.js @@ -2,6 +2,7 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; import http from "node:http"; import { ClaudeCodeProxy } from "../dist/index.js"; +import { ProviderState } from "../dist/provider-health.js"; function createProxy(overrides = {}) { return new ClaudeCodeProxy({ @@ -103,6 +104,45 @@ describe("Claude Code Proxy fallback chain", () => { assert.equal(response.status, 200); assert.deepEqual(providersCalled, ["anthropic"]); + assert.equal(proxy.providerHealth.getState("anthropic"), ProviderState.COOLING_DOWN); + proxy.providerHealth.destroy(); + }); + + it("records primary provider quota failures before falling back", async () => { + const proxy = createProxy(); + + proxy.trySubscriptionRequest = async () => undefined; + proxy.requestProvider = async (provider) => { + if (provider === "anthropic") { + return { + response: jsonResponse(403, { + error: { message: "quota exceeded for this account" }, + }), + errorType: "rate_limit", + }; + } + + return { response: jsonResponse(200, { id: "fallback-ok" }) }; + }; + + try { + const response = await proxy.proxyRequest( + JSON.stringify({ + model: "claude-sonnet-4-6", + max_tokens: 16, + messages: [{ role: "user", content: "hello" }], + }), + { "content-type": "application/json" }, + "/v1/messages", + "POST", + ); + + assert.equal(response.status, 200); + assert.equal(proxy.providerHealth.getState("anthropic"), ProviderState.COOLING_DOWN); + assert.equal(proxy.providerHealth.isAvailable("anthropic"), false); + } finally { + proxy.providerHealth.destroy(); + } }); it("falls through anthropic and zai before using openrouter", async () => { @@ -194,6 +234,47 @@ describe("Claude Code Proxy fallback chain", () => { assert.equal(response, undefined); }); + it("opens a cooldown circuit after Claude subscription limit responses", async () => { + const proxy = createProxy({ + circuitBreaker: { + cooldownMs: 1000, + }, + }); + let upstreamCalls = 0; + + proxy.readClaudeOAuthToken = async () => "oauth-token"; + proxy.httpRequest = async () => { + upstreamCalls++; + return jsonResponse(429, { error: { message: "usage limit reached" } }); + }; + + const requestBody = JSON.stringify({ + model: "claude-sonnet-4-6", + max_tokens: 16, + messages: [{ role: "user", content: "hello" }], + }); + + const first = await proxy.trySubscriptionRequest( + requestBody, + { "content-type": "application/json" }, + "/v1/messages", + "POST", + ); + const second = await proxy.trySubscriptionRequest( + requestBody, + { "content-type": "application/json" }, + "/v1/messages", + "POST", + ); + + assert.equal(first, undefined); + assert.equal(second, undefined); + assert.equal(upstreamCalls, 1); + assert.equal(proxy.getClaudeSubscriptionState().state, "cooling_down"); + + proxy.providerHealth.destroy(); + }); + it("routes Claude subscription OAuth requests to the Anthropic API host", async () => { const proxy = createProxy(); let capturedUrl = ""; @@ -313,6 +394,72 @@ describe("Claude Code Proxy fallback chain", () => { } }); + it("falls back to Z.AI when Claude subscription streams an immediate limit error", async () => { + const subscriptionUpstream = await startUpstream((_req, res) => { + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end( + 'event: error\n' + + 'data: {"type":"error","error":{"type":"rate_limit_error","message":"usage limit reached"}}\n\n' + ); + }); + const zaiUpstream = await startUpstream((req, res) => { + const chunks = []; + req.on("data", (chunk) => chunks.push(chunk)); + req.on("end", () => { + const parsed = JSON.parse(Buffer.concat(chunks).toString()); + res.writeHead(200, { "content-type": "text/event-stream" }); + res.end(`data: {"type":"message_start","message":{"model":"${parsed.model}"}}\n\n`); + }); + }); + let proxy; + + try { + proxy = createProxy({ + anthropic: { + apiKey: "", + }, + zai: { + baseUrl: zaiUpstream.baseUrl, + apiKey: "zai-test-key", + }, + claudeSubscription: { + enabled: true, + baseUrl: subscriptionUpstream.baseUrl, + credentialsPath: "test-credentials.json", + }, + circuitBreaker: { + cooldownMs: 1000, + }, + }); + + proxy.providerHealth.getBestProviderForModel = () => "zai"; + proxy.readClaudeOAuthToken = async () => "oauth-token"; + const clientRes = createFakeResponse(); + + await proxy.handleStreamingRequest( + JSON.stringify({ + model: "claude-sonnet-4-6", + max_tokens: 16, + stream: true, + messages: [{ role: "user", content: "hello" }], + }), + { "content-type": "application/json" }, + "/v1/messages", + "POST", + clientRes, + ); + await clientRes.ended; + + assert.equal(clientRes.statusCode, 200); + assert.match(Buffer.concat(clientRes.chunks).toString(), /"model":"glm-4\.7"/); + assert.equal(proxy.getClaudeSubscriptionState().state, "cooling_down"); + } finally { + proxy?.providerHealth.destroy(); + await closeServer(subscriptionUpstream.server); + await closeServer(zaiUpstream.server); + } + }); + it("orders fallback providers by priority and skips providers without keys", () => { const proxy = createProxy({ openrouter: { @@ -355,6 +502,39 @@ describe("Claude Code Proxy provider request normalization", () => { assert.equal(capturedUrl, "https://api.z.ai/api/anthropic/v1/messages"); }); + it("classifies quota-style provider responses as rate limits", async () => { + const upstream = await startUpstream((_req, res) => { + res.writeHead(403, { "content-type": "application/json" }); + res.end(JSON.stringify({ error: { message: "quota exceeded for this account" } })); + }); + const proxy = createProxy({ + anthropic: { + baseUrl: upstream.baseUrl, + apiKey: "anthropic-test-key", + }, + }); + + try { + const result = await proxy.requestProvider( + "anthropic", + JSON.stringify({ + model: "claude-sonnet-4-6", + max_tokens: 16, + messages: [{ role: "user", content: "hello" }], + }), + { "content-type": "application/json" }, + "/v1/messages", + "POST", + ); + + assert.equal(result.response.status, 403); + assert.equal(result.errorType, "rate_limit"); + } finally { + proxy.providerHealth.destroy(); + await closeServer(upstream.server); + } + }); + it("rewrites openrouter requests to the messages endpoint and remaps models", async () => { const proxy = createProxy(); let capturedUrl = "";