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 = "";