@timo972/cc-router 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +96 -0
- package/Dockerfile +42 -0
- package/LICENSE +21 -0
- package/README.md +716 -0
- package/accounts.example.json +25 -0
- package/dist/cli/cmd-accounts.js +248 -0
- package/dist/cli/cmd-client.js +612 -0
- package/dist/cli/cmd-configure.js +145 -0
- package/dist/cli/cmd-docker.js +140 -0
- package/dist/cli/cmd-logs.js +85 -0
- package/dist/cli/cmd-models.js +125 -0
- package/dist/cli/cmd-service.js +193 -0
- package/dist/cli/cmd-setup.js +501 -0
- package/dist/cli/cmd-start.js +318 -0
- package/dist/cli/cmd-status.js +177 -0
- package/dist/cli/cmd-stop.js +100 -0
- package/dist/cli/cmd-telemetry.js +58 -0
- package/dist/cli/cmd-update.js +37 -0
- package/dist/cli/index.js +59 -0
- package/dist/config/manager.js +262 -0
- package/dist/config/paths.js +21 -0
- package/dist/config/telemetry.js +64 -0
- package/dist/daemon/launcher.js +163 -0
- package/dist/daemon/pid.js +98 -0
- package/dist/daemon/service.js +260 -0
- package/dist/interceptor/mitmproxy-manager.js +616 -0
- package/dist/protocol/anthropic-to-openai.js +51 -0
- package/dist/protocol/anthropic-types.js +1 -0
- package/dist/protocol/model-ref.js +36 -0
- package/dist/protocol/model-routing-config.js +30 -0
- package/dist/protocol/openai-response-to-anthropic.js +20 -0
- package/dist/protocol/openai-responses-types.js +1 -0
- package/dist/protocol/openai-stream-to-anthropic.js +75 -0
- package/dist/protocol/openai-to-anthropic.js +61 -0
- package/dist/protocol/sse.js +17 -0
- package/dist/providers/model-discovery.js +71 -0
- package/dist/providers/openai/account-pool.js +11 -0
- package/dist/providers/openai/account-record.js +33 -0
- package/dist/providers/openai/codex-transport.js +36 -0
- package/dist/providers/openai/device-oauth.js +116 -0
- package/dist/providers/openai/token-refresher.js +56 -0
- package/dist/providers/route-selector.js +8 -0
- package/dist/providers/types.js +1 -0
- package/dist/proxy/account-deletion.js +44 -0
- package/dist/proxy/anthropic-proxy.js +26 -0
- package/dist/proxy/anthropic-routing.js +90 -0
- package/dist/proxy/lease-lifecycle.js +68 -0
- package/dist/proxy/logger.js +39 -0
- package/dist/proxy/messages-cross-route.js +179 -0
- package/dist/proxy/models-server.js +150 -0
- package/dist/proxy/provider-routing.js +14 -0
- package/dist/proxy/responses-server.js +91 -0
- package/dist/proxy/server.js +875 -0
- package/dist/proxy/session-router.js +171 -0
- package/dist/proxy/stats.js +25 -0
- package/dist/proxy/stream-lifecycle.js +83 -0
- package/dist/proxy/token-pool.js +407 -0
- package/dist/proxy/token-refresher.js +209 -0
- package/dist/proxy/types.js +29 -0
- package/dist/ui/Dashboard.js +640 -0
- package/dist/ui/accountsApi.js +48 -0
- package/dist/ui/modelsApi.js +47 -0
- package/dist/utils/claude-config.js +185 -0
- package/dist/utils/codex-config.js +62 -0
- package/dist/utils/network.js +16 -0
- package/dist/utils/platform.js +13 -0
- package/dist/utils/self-update.js +239 -0
- package/dist/utils/telemetry.js +88 -0
- package/dist/utils/token-extractor.js +95 -0
- package/dist/utils/token-validator.js +26 -0
- package/docker-compose.yml +63 -0
- package/litellm-config.yaml +44 -0
- package/package.json +69 -0
- package/src/interceptor/addon.py +78 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import os from "os";
|
|
2
|
+
import { isTelemetryEnabled, loadTelemetryState } from "../config/telemetry.js";
|
|
3
|
+
import { detectPlatform } from "./platform.js";
|
|
4
|
+
import { getCurrentVersion } from "./self-update.js";
|
|
5
|
+
// ─── Aptabase configuration ──────────────────────────────────────────────────
|
|
6
|
+
// Aptabase is a privacy-first, open source analytics service.
|
|
7
|
+
// The full payload we send is documented below — search for "trackEvent" calls
|
|
8
|
+
// in the codebase to audit every event. Nothing here contains PII.
|
|
9
|
+
const APTABASE_APP_KEY = "A-EU-1060569594";
|
|
10
|
+
const APTABASE_ENDPOINT = "https://eu.aptabase.com/api/v0/event";
|
|
11
|
+
const TIMEOUT_MS = 3_000;
|
|
12
|
+
function getOsName() {
|
|
13
|
+
switch (detectPlatform()) {
|
|
14
|
+
case "macos": return "macOS";
|
|
15
|
+
case "linux": return "Linux";
|
|
16
|
+
case "windows": return "Windows";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function getLocale() {
|
|
20
|
+
try {
|
|
21
|
+
// Aptabase limits locale to 10 characters — truncate extended subtags
|
|
22
|
+
const raw = Intl.DateTimeFormat().resolvedOptions().locale;
|
|
23
|
+
return raw.length <= 10 ? raw : raw.slice(0, 10);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return process.env["LANG"]?.split(".")[0]?.slice(0, 10) ?? "unknown";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
function getSystemProps() {
|
|
30
|
+
return {
|
|
31
|
+
isDebug: false,
|
|
32
|
+
locale: getLocale(),
|
|
33
|
+
osName: getOsName(),
|
|
34
|
+
osVersion: os.release(),
|
|
35
|
+
appVersion: getCurrentVersion(),
|
|
36
|
+
engineName: "node",
|
|
37
|
+
engineVersion: process.versions.node,
|
|
38
|
+
sdkVersion: `cc-router@${getCurrentVersion()}`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
// Session ID — use the installId directly so Aptabase always identifies the
|
|
42
|
+
// same machine as the same user. Aptabase limits sessionId to 36 characters;
|
|
43
|
+
// a standard UUID with dashes is exactly 36, so we pass it through unchanged.
|
|
44
|
+
function getSessionId(installId) {
|
|
45
|
+
return installId.slice(0, 36);
|
|
46
|
+
}
|
|
47
|
+
// ─── Public API ──────────────────────────────────────────────────────────────
|
|
48
|
+
// Fire-and-forget: never throws, never blocks the caller. If telemetry is
|
|
49
|
+
// disabled (env var or opt-out) this is a synchronous no-op.
|
|
50
|
+
export async function trackEvent(eventName, props) {
|
|
51
|
+
try {
|
|
52
|
+
if (!isTelemetryEnabled())
|
|
53
|
+
return;
|
|
54
|
+
const state = loadTelemetryState();
|
|
55
|
+
const body = {
|
|
56
|
+
timestamp: new Date().toISOString(),
|
|
57
|
+
sessionId: getSessionId(state.installId),
|
|
58
|
+
eventName,
|
|
59
|
+
systemProps: getSystemProps(),
|
|
60
|
+
props: props ?? {},
|
|
61
|
+
};
|
|
62
|
+
await fetch(APTABASE_ENDPOINT, {
|
|
63
|
+
method: "POST",
|
|
64
|
+
headers: {
|
|
65
|
+
"Content-Type": "application/json",
|
|
66
|
+
"App-Key": APTABASE_APP_KEY,
|
|
67
|
+
},
|
|
68
|
+
body: JSON.stringify(body),
|
|
69
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
// Silently swallow — telemetry must never disrupt the proxy
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Start a heartbeat that fires every hour while the proxy is running.
|
|
77
|
+
// Uses .unref() so the timer does not prevent Node from exiting.
|
|
78
|
+
export function startHeartbeat(accountCount) {
|
|
79
|
+
const startTime = Date.now();
|
|
80
|
+
const timer = setInterval(() => {
|
|
81
|
+
const uptimeMinutes = Math.floor((Date.now() - startTime) / 60_000);
|
|
82
|
+
trackEvent("proxy_heartbeat", {
|
|
83
|
+
uptime_minutes: uptimeMinutes,
|
|
84
|
+
account_count: accountCount,
|
|
85
|
+
});
|
|
86
|
+
}, 60 * 60 * 1000);
|
|
87
|
+
timer.unref();
|
|
88
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { execFile } from "child_process";
|
|
2
|
+
import { promisify } from "util";
|
|
3
|
+
import { readFileSync, existsSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import os from "os";
|
|
6
|
+
const execFileAsync = promisify(execFile);
|
|
7
|
+
/**
|
|
8
|
+
* macOS: extract OAuth tokens from the macOS Keychain.
|
|
9
|
+
* Uses execFile (not exec/execSync) — args are passed as an array,
|
|
10
|
+
* preventing any shell injection.
|
|
11
|
+
*/
|
|
12
|
+
export async function extractFromKeychain() {
|
|
13
|
+
try {
|
|
14
|
+
const { stdout } = await execFileAsync("security", [
|
|
15
|
+
"find-generic-password",
|
|
16
|
+
"-s", "Claude Code-credentials",
|
|
17
|
+
"-w",
|
|
18
|
+
]);
|
|
19
|
+
const raw = JSON.parse(stdout.trim());
|
|
20
|
+
// Keychain JSON can be either:
|
|
21
|
+
// { claudeAiOauth: { accessToken, refreshToken, ... }, mcpOAuth: {...} }
|
|
22
|
+
// { accessToken, refreshToken, ... } (direct, older versions)
|
|
23
|
+
const oauth = raw.claudeAiOauth ?? raw;
|
|
24
|
+
return parseCredentialJson(oauth);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Linux / Windows: read from ~/.claude/.credentials.json.
|
|
32
|
+
* Claude Code writes credentials here on non-macOS platforms.
|
|
33
|
+
* No shell — pure Node.js file read.
|
|
34
|
+
*/
|
|
35
|
+
export function extractFromCredentialsFile() {
|
|
36
|
+
const credPath = join(os.homedir(), ".claude", ".credentials.json");
|
|
37
|
+
if (!existsSync(credPath))
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const raw = JSON.parse(readFileSync(credPath, "utf-8"));
|
|
41
|
+
// The file can have two shapes:
|
|
42
|
+
// { claudeAiOauth: { accessToken, refreshToken, expiresAt, scopes } }
|
|
43
|
+
// { accessToken, refreshToken, expiresAt, scopes } (direct)
|
|
44
|
+
const oauth = raw.claudeAiOauth ?? raw;
|
|
45
|
+
return parseCredentialJson(oauth);
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Parse and normalise either a raw JSON string or an already-parsed object. */
|
|
52
|
+
function parseCredentialJson(raw) {
|
|
53
|
+
try {
|
|
54
|
+
const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
|
|
55
|
+
const accessToken = obj["accessToken"];
|
|
56
|
+
const refreshToken = obj["refreshToken"];
|
|
57
|
+
const expiresAt = obj["expiresAt"];
|
|
58
|
+
if (typeof accessToken !== "string" ||
|
|
59
|
+
typeof refreshToken !== "string" ||
|
|
60
|
+
!accessToken.startsWith("sk-ant-")) {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
const scopes = Array.isArray(obj["scopes"])
|
|
64
|
+
? obj["scopes"]
|
|
65
|
+
: ["user:inference", "user:profile"];
|
|
66
|
+
let expiresAtMs;
|
|
67
|
+
if (typeof expiresAt === "number") {
|
|
68
|
+
expiresAtMs = expiresAt;
|
|
69
|
+
}
|
|
70
|
+
else if (typeof expiresAt === "string") {
|
|
71
|
+
expiresAtMs = new Date(expiresAt).getTime();
|
|
72
|
+
}
|
|
73
|
+
else {
|
|
74
|
+
// No expiry info — assume 8h from now (standard OAuth token lifetime)
|
|
75
|
+
expiresAtMs = Date.now() + 8 * 60 * 60 * 1000;
|
|
76
|
+
}
|
|
77
|
+
return { accessToken, refreshToken, expiresAt: expiresAtMs, scopes };
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/** Format a token expiry timestamp as a human-readable string */
|
|
84
|
+
export function formatExpiry(expiresAtMs) {
|
|
85
|
+
const ms = expiresAtMs - Date.now();
|
|
86
|
+
if (ms <= 0)
|
|
87
|
+
return "EXPIRED";
|
|
88
|
+
const h = Math.floor(ms / 3_600_000);
|
|
89
|
+
const m = Math.floor((ms % 3_600_000) / 60_000);
|
|
90
|
+
return h > 0 ? `${h}h ${m}m` : `${m}m`;
|
|
91
|
+
}
|
|
92
|
+
/** Redact a token for safe display: show first 20 chars + "..." */
|
|
93
|
+
export function redactToken(token) {
|
|
94
|
+
return token.length > 20 ? `${token.slice(0, 20)}...` : token;
|
|
95
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export async function validateToken(accessToken) {
|
|
2
|
+
try {
|
|
3
|
+
const res = await fetch("https://api.anthropic.com/v1/models", {
|
|
4
|
+
headers: {
|
|
5
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
6
|
+
"anthropic-version": "2023-06-01",
|
|
7
|
+
// Required for api.anthropic.com to accept OAuth tokens (sk-ant-oat01-*)
|
|
8
|
+
"anthropic-beta": "oauth-2025-04-20",
|
|
9
|
+
},
|
|
10
|
+
});
|
|
11
|
+
if (res.ok)
|
|
12
|
+
return { valid: true };
|
|
13
|
+
if (res.status === 401) {
|
|
14
|
+
return { valid: false, reason: "Token invalid or expired (401)" };
|
|
15
|
+
}
|
|
16
|
+
if (res.status === 403) {
|
|
17
|
+
return { valid: false, reason: "Token lacks required scopes (403) — needs user:inference" };
|
|
18
|
+
}
|
|
19
|
+
// Any other non-ok status is unexpected but the token may still work
|
|
20
|
+
return { valid: false, reason: `Unexpected HTTP ${res.status}` };
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
// Network error — can't validate, let user decide
|
|
24
|
+
return { valid: false, reason: `Network error: ${err.message}` };
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
services:
|
|
2
|
+
cc-router:
|
|
3
|
+
build:
|
|
4
|
+
context: .
|
|
5
|
+
dockerfile: Dockerfile
|
|
6
|
+
ports:
|
|
7
|
+
# Bind to localhost by default. To serve other devices, set a proxySecret
|
|
8
|
+
# (see docs/security.md) and change this to "0.0.0.0:${PORT:-3456}:3456".
|
|
9
|
+
- "127.0.0.1:${PORT:-3456}:3456"
|
|
10
|
+
volumes:
|
|
11
|
+
# Mount accounts.json read-write: cc-router must update it when tokens rotate
|
|
12
|
+
- ${HOME}/.cc-router/accounts.json:/app/accounts.json
|
|
13
|
+
environment:
|
|
14
|
+
- PORT=3456
|
|
15
|
+
- LITELLM_URL=http://litellm:4000
|
|
16
|
+
- ACCOUNTS_PATH=/app/accounts.json
|
|
17
|
+
- NODE_ENV=production
|
|
18
|
+
depends_on:
|
|
19
|
+
litellm:
|
|
20
|
+
condition: service_healthy
|
|
21
|
+
restart: unless-stopped
|
|
22
|
+
# Least privilege: no capabilities, no privilege escalation.
|
|
23
|
+
cap_drop:
|
|
24
|
+
- ALL
|
|
25
|
+
security_opt:
|
|
26
|
+
- no-new-privileges:true
|
|
27
|
+
healthcheck:
|
|
28
|
+
test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3456/cc-router/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
|
|
29
|
+
interval: 30s
|
|
30
|
+
timeout: 10s
|
|
31
|
+
retries: 3
|
|
32
|
+
start_period: 10s
|
|
33
|
+
|
|
34
|
+
litellm:
|
|
35
|
+
# Pinned by digest (snapshot of main-stable at pin time) — a moving tag can
|
|
36
|
+
# silently ship a bad build. The BerriAI 1.82.7/1.82.8 releases contained
|
|
37
|
+
# malware; a digest pin prevents a re-tag from re-introducing that class of
|
|
38
|
+
# supply-chain risk. Update deliberately after vetting the release:
|
|
39
|
+
# crane digest ghcr.io/berriai/litellm:main-stable
|
|
40
|
+
image: ghcr.io/berriai/litellm:main-stable@sha256:a1745e629abfb17d434426ff48b115f54f4f4c4a0f5af241de569e93c63c411e
|
|
41
|
+
ports:
|
|
42
|
+
# Localhost only — the LiteLLM admin UI/key management must not be exposed.
|
|
43
|
+
- "127.0.0.1:${LITELLM_PORT:-4000}:4000"
|
|
44
|
+
volumes:
|
|
45
|
+
- ./litellm-config.yaml:/app/config.yaml:ro
|
|
46
|
+
environment:
|
|
47
|
+
# Require an explicit master key — fail fast instead of falling back to a
|
|
48
|
+
# published, guessable default that would gate the admin plane.
|
|
49
|
+
- LITELLM_MASTER_KEY=${LITELLM_MASTER_KEY:?set LITELLM_MASTER_KEY in your .env}
|
|
50
|
+
# No --detailed_debug: with forward_client_headers_to_llm_api it would write
|
|
51
|
+
# the injected "Authorization: Bearer <oauth-token>" into container logs.
|
|
52
|
+
command: ["--config", "/app/config.yaml", "--port", "4000"]
|
|
53
|
+
restart: unless-stopped
|
|
54
|
+
cap_drop:
|
|
55
|
+
- ALL
|
|
56
|
+
security_opt:
|
|
57
|
+
- no-new-privileges:true
|
|
58
|
+
healthcheck:
|
|
59
|
+
test: ["CMD-SHELL", "curl -sf http://localhost:4000/health || exit 1"]
|
|
60
|
+
interval: 15s
|
|
61
|
+
timeout: 5s
|
|
62
|
+
retries: 5
|
|
63
|
+
start_period: 20s
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
model_list:
|
|
2
|
+
# ── Claude 4.6 (latest) ───────────────────────────────────────────────────
|
|
3
|
+
- model_name: claude-opus-4-6
|
|
4
|
+
litellm_params:
|
|
5
|
+
model: anthropic/claude-opus-4-6
|
|
6
|
+
# No api_key — Authorization header is injected by cc-router (OAuth token)
|
|
7
|
+
|
|
8
|
+
- model_name: claude-sonnet-4-6
|
|
9
|
+
litellm_params:
|
|
10
|
+
model: anthropic/claude-sonnet-4-6
|
|
11
|
+
|
|
12
|
+
# ── Claude 4.5 ────────────────────────────────────────────────────────────
|
|
13
|
+
- model_name: claude-sonnet-4-5-20250929
|
|
14
|
+
litellm_params:
|
|
15
|
+
model: anthropic/claude-sonnet-4-5-20250929
|
|
16
|
+
|
|
17
|
+
- model_name: claude-haiku-4-5-20251001
|
|
18
|
+
litellm_params:
|
|
19
|
+
model: anthropic/claude-haiku-4-5-20251001
|
|
20
|
+
|
|
21
|
+
# ── Short-name aliases (Claude Code uses these interchangeably) ───────────
|
|
22
|
+
- model_name: claude-sonnet-4-5
|
|
23
|
+
litellm_params:
|
|
24
|
+
model: anthropic/claude-sonnet-4-5-20250929
|
|
25
|
+
|
|
26
|
+
- model_name: claude-haiku-4-5
|
|
27
|
+
litellm_params:
|
|
28
|
+
model: anthropic/claude-haiku-4-5-20251001
|
|
29
|
+
|
|
30
|
+
general_settings:
|
|
31
|
+
# CRITICAL: forward the Authorization header from cc-router to Anthropic.
|
|
32
|
+
# cc-router injects "Authorization: Bearer <oauth-token>" on every request.
|
|
33
|
+
# Without this setting, LiteLLM would strip it and auth would fail.
|
|
34
|
+
forward_client_headers_to_llm_api: true
|
|
35
|
+
|
|
36
|
+
# Master key for LiteLLM UI and virtual key management.
|
|
37
|
+
# Set LITELLM_MASTER_KEY in your environment or .env file.
|
|
38
|
+
master_key: os.environ/LITELLM_MASTER_KEY
|
|
39
|
+
|
|
40
|
+
litellm_settings:
|
|
41
|
+
# High timeouts for Claude Code long-running requests (thinking, agents)
|
|
42
|
+
request_timeout: 300
|
|
43
|
+
# Drop params not supported by a model silently (avoids errors on older models)
|
|
44
|
+
drop_params: true
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@timo972/cc-router",
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Cache-aware session router for Claude Max OAuth tokens — use multiple Claude Max accounts with Claude Code",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"cc-router": "dist/cli/index.js"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"dev": "tsx src/cli/index.ts",
|
|
12
|
+
"start": "node dist/cli/index.js",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"test:watch": "vitest",
|
|
15
|
+
"lint": "tsc --noEmit"
|
|
16
|
+
},
|
|
17
|
+
"keywords": [
|
|
18
|
+
"claude",
|
|
19
|
+
"anthropic",
|
|
20
|
+
"proxy",
|
|
21
|
+
"oauth",
|
|
22
|
+
"session-routing",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"claude-max"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "git+https://github.com/Timo972/cc-router.git"
|
|
30
|
+
},
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/Timo972/cc-router/issues"
|
|
33
|
+
},
|
|
34
|
+
"homepage": "https://github.com/Timo972/cc-router#readme",
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist/",
|
|
40
|
+
"src/interceptor/addon.py",
|
|
41
|
+
"litellm-config.yaml",
|
|
42
|
+
"docker-compose.yml",
|
|
43
|
+
"Dockerfile",
|
|
44
|
+
"accounts.example.json",
|
|
45
|
+
"README.md",
|
|
46
|
+
"CHANGELOG.md",
|
|
47
|
+
"LICENSE"
|
|
48
|
+
],
|
|
49
|
+
"dependencies": {
|
|
50
|
+
"@inquirer/prompts": "^7.0.0",
|
|
51
|
+
"chalk": "^5.3.0",
|
|
52
|
+
"commander": "^12.0.0",
|
|
53
|
+
"express": "^4.21.0",
|
|
54
|
+
"http-proxy-middleware": "^3.0.5",
|
|
55
|
+
"ink": "^5.0.0",
|
|
56
|
+
"react": "^18.3.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/express": "^4.17.21",
|
|
60
|
+
"@types/node": "^20.0.0",
|
|
61
|
+
"@types/react": "^18.3.0",
|
|
62
|
+
"tsx": "^4.19.0",
|
|
63
|
+
"typescript": "^5.6.0",
|
|
64
|
+
"vitest": "^4.1.2"
|
|
65
|
+
},
|
|
66
|
+
"engines": {
|
|
67
|
+
"node": ">=20.0.0"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# mitmproxy addon — redirects ONLY /v1/messages traffic to CC-Router and
|
|
2
|
+
# injects the proxy secret as an auth header.
|
|
3
|
+
#
|
|
4
|
+
# There are TWO cases to handle:
|
|
5
|
+
#
|
|
6
|
+
# 1. Requests to api.anthropic.com (Claude Desktop native features)
|
|
7
|
+
# → rewrite host/port to CC-Router target + inject x-api-key
|
|
8
|
+
#
|
|
9
|
+
# 2. Requests already pointed at the CC-Router target host (Claude Code
|
|
10
|
+
# inside Desktop Cowork/Agent mode, which reads ~/.claude/settings.json
|
|
11
|
+
# and goes direct to ANTHROPIC_BASE_URL)
|
|
12
|
+
# → inject x-api-key (no rewrite needed)
|
|
13
|
+
#
|
|
14
|
+
# Claude Desktop sends many types of requests:
|
|
15
|
+
# /v1/messages → LLM inference (redirect + auth)
|
|
16
|
+
# /v1/messages/count_tokens → token counting (redirect + auth)
|
|
17
|
+
# /v1/oauth/* → session auth (must NOT touch)
|
|
18
|
+
# /v1/environments/* → bridge/cowork (must NOT touch)
|
|
19
|
+
# /v1/models → model listing (redirect + auth)
|
|
20
|
+
# /api/* → desktop features (must NOT touch)
|
|
21
|
+
#
|
|
22
|
+
# Only /v1/messages* and /v1/models are safe to touch because CC-Router
|
|
23
|
+
# injects its own OAuth token. Everything else carries the user's own
|
|
24
|
+
# session token for features CC-Router doesn't handle.
|
|
25
|
+
|
|
26
|
+
import os
|
|
27
|
+
from urllib.parse import urlparse
|
|
28
|
+
|
|
29
|
+
from mitmproxy import http
|
|
30
|
+
|
|
31
|
+
_target_raw = os.environ.get("CC_ROUTER_TARGET", "http://localhost:3456")
|
|
32
|
+
_target = _target_raw.rstrip("/")
|
|
33
|
+
_target_parsed = urlparse(_target)
|
|
34
|
+
|
|
35
|
+
if not _target_parsed.scheme or not _target_parsed.netloc:
|
|
36
|
+
raise RuntimeError(f"CC_ROUTER_TARGET is not a valid URL: {_target_raw!r}")
|
|
37
|
+
|
|
38
|
+
_target_host = (_target_parsed.hostname or "").lower()
|
|
39
|
+
_target_port = _target_parsed.port or (443 if _target_parsed.scheme == "https" else 80)
|
|
40
|
+
|
|
41
|
+
# Optional proxy secret — when set, injected as x-api-key on routed requests
|
|
42
|
+
_secret = os.environ.get("CC_ROUTER_SECRET", "")
|
|
43
|
+
|
|
44
|
+
# Paths that CC-Router can handle (it injects its own OAuth token)
|
|
45
|
+
_REDIRECT_PREFIXES = (
|
|
46
|
+
"/v1/messages",
|
|
47
|
+
"/v1/models",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def request(flow: http.HTTPFlow) -> None:
|
|
52
|
+
host = (flow.request.pretty_host or "").lower()
|
|
53
|
+
port = flow.request.port
|
|
54
|
+
is_anthropic = host == "api.anthropic.com"
|
|
55
|
+
is_target = host == _target_host and port == _target_port
|
|
56
|
+
|
|
57
|
+
# Not a host we care about — pass through untouched
|
|
58
|
+
if not is_anthropic and not is_target:
|
|
59
|
+
return
|
|
60
|
+
|
|
61
|
+
# Only touch inference and model-listing paths
|
|
62
|
+
if not flow.request.path.startswith(_REDIRECT_PREFIXES):
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
# Case 1: rewrite api.anthropic.com → CC-Router target
|
|
66
|
+
if is_anthropic:
|
|
67
|
+
flow.request.scheme = _target_parsed.scheme
|
|
68
|
+
flow.request.host = _target_host or "localhost"
|
|
69
|
+
flow.request.port = _target_port
|
|
70
|
+
flow.request.headers["host"] = flow.request.host + (
|
|
71
|
+
f":{flow.request.port}"
|
|
72
|
+
if flow.request.port not in (80, 443)
|
|
73
|
+
else ""
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
# Case 1 and 2: authenticate against the proxy if a secret is configured
|
|
77
|
+
if _secret:
|
|
78
|
+
flow.request.headers["x-api-key"] = _secret
|