@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,209 @@
|
|
|
1
|
+
import { writeAnthropicAccountsPreservingOtherProviders, serialize } from "../config/manager.js";
|
|
2
|
+
import { logRefresh } from "./logger.js";
|
|
3
|
+
import { stats } from "./stats.js";
|
|
4
|
+
/**
|
|
5
|
+
* Official Claude Code CLI client_id for the OAuth PKCE flow.
|
|
6
|
+
* Source: extracted from Claude Code auth flow.
|
|
7
|
+
* Update this if Anthropic changes it in a future Claude Code version.
|
|
8
|
+
*/
|
|
9
|
+
const CLAUDE_CODE_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
|
10
|
+
/**
|
|
11
|
+
* Primary OAuth token endpoint.
|
|
12
|
+
* Alternative: https://claude.ai/v1/oauth/token
|
|
13
|
+
*/
|
|
14
|
+
const TOKEN_ENDPOINT = "https://claude.ai/v1/oauth/token";
|
|
15
|
+
/** Refresh 10 minutes before expiry */
|
|
16
|
+
const REFRESH_BUFFER_MS = 10 * 60 * 1000;
|
|
17
|
+
/** Check every 5 minutes */
|
|
18
|
+
const CHECK_INTERVAL_MS = 5 * 60 * 1000;
|
|
19
|
+
/** Exact-object locks prevent stale account incarnations from sharing work. */
|
|
20
|
+
const rawRefreshLocks = new Map();
|
|
21
|
+
const ownedRefreshLocks = new Map();
|
|
22
|
+
/** A count is required because concurrent deletion attempts may reserve the same object. */
|
|
23
|
+
const deletionReservations = new Map();
|
|
24
|
+
/** Rotated credentials that still need to be durably written must not rotate again. */
|
|
25
|
+
const pendingDurability = new WeakSet();
|
|
26
|
+
function isReservedForDeletion(account) {
|
|
27
|
+
return (deletionReservations.get(account) ?? 0) > 0;
|
|
28
|
+
}
|
|
29
|
+
export function needsRefresh(account) {
|
|
30
|
+
return ownedRefreshLocks.has(account) ||
|
|
31
|
+
pendingDurability.has(account) ||
|
|
32
|
+
(account.tokens.expiresAt - Date.now()) < REFRESH_BUFFER_MS;
|
|
33
|
+
}
|
|
34
|
+
export async function refreshAccountToken(account) {
|
|
35
|
+
// A deletion reservation rejects every new caller, including callers that
|
|
36
|
+
// would otherwise attach themselves to already-running raw refresh work.
|
|
37
|
+
if (isReservedForDeletion(account))
|
|
38
|
+
return false;
|
|
39
|
+
// Deduplicate concurrent refresh calls for the same account
|
|
40
|
+
const existing = rawRefreshLocks.get(account);
|
|
41
|
+
if (existing)
|
|
42
|
+
return existing;
|
|
43
|
+
const promise = _doRefresh(account);
|
|
44
|
+
rawRefreshLocks.set(account, promise);
|
|
45
|
+
try {
|
|
46
|
+
return await promise;
|
|
47
|
+
}
|
|
48
|
+
finally {
|
|
49
|
+
if (rawRefreshLocks.get(account) === promise)
|
|
50
|
+
rawRefreshLocks.delete(account);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Wait for refresh work owned by this exact account incarnation, if any. */
|
|
54
|
+
export async function waitForAccountRefresh(account) {
|
|
55
|
+
const activeRefresh = rawRefreshLocks.get(account);
|
|
56
|
+
const activeOwnedRefresh = ownedRefreshLocks.get(account);
|
|
57
|
+
await Promise.allSettled([activeRefresh, activeOwnedRefresh].filter((promise) => promise !== undefined));
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Prevent new refreshes for an exact account object and wait for all work that
|
|
61
|
+
* started before the reservation, including owned persistence, to settle.
|
|
62
|
+
*/
|
|
63
|
+
export async function reserveAccountForDeletion(account) {
|
|
64
|
+
deletionReservations.set(account, (deletionReservations.get(account) ?? 0) + 1);
|
|
65
|
+
let released = false;
|
|
66
|
+
const release = () => {
|
|
67
|
+
if (released)
|
|
68
|
+
return;
|
|
69
|
+
released = true;
|
|
70
|
+
const remaining = (deletionReservations.get(account) ?? 1) - 1;
|
|
71
|
+
if (remaining > 0)
|
|
72
|
+
deletionReservations.set(account, remaining);
|
|
73
|
+
else
|
|
74
|
+
deletionReservations.delete(account);
|
|
75
|
+
};
|
|
76
|
+
try {
|
|
77
|
+
await waitForAccountRefresh(account);
|
|
78
|
+
return release;
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
release();
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function performOwnedRefresh(account, pool, options) {
|
|
86
|
+
if (pool.findById(account.id) !== account)
|
|
87
|
+
return false;
|
|
88
|
+
if (pendingDurability.has(account)) {
|
|
89
|
+
(options.persist ?? saveAccounts)(pool.getAll());
|
|
90
|
+
pendingDurability.delete(account);
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
const ok = await (options.refresh ?? refreshAccountToken)(account);
|
|
94
|
+
if (!ok || pool.findById(account.id) !== account)
|
|
95
|
+
return false;
|
|
96
|
+
try {
|
|
97
|
+
(options.persist ?? saveAccounts)(pool.getAll());
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
pendingDurability.add(account);
|
|
101
|
+
throw error;
|
|
102
|
+
}
|
|
103
|
+
pendingDurability.delete(account);
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Refresh and persist only while the pool still owns this exact object.
|
|
108
|
+
* Production callers for the same object coalesce through persistence.
|
|
109
|
+
*/
|
|
110
|
+
export function refreshAccountIfCurrent(account, pool, options = {}) {
|
|
111
|
+
if (isReservedForDeletion(account))
|
|
112
|
+
return Promise.resolve(false);
|
|
113
|
+
const existing = ownedRefreshLocks.get(account);
|
|
114
|
+
if (existing)
|
|
115
|
+
return existing;
|
|
116
|
+
let operation;
|
|
117
|
+
operation = (async () => {
|
|
118
|
+
try {
|
|
119
|
+
return await performOwnedRefresh(account, pool, options);
|
|
120
|
+
}
|
|
121
|
+
finally {
|
|
122
|
+
if (ownedRefreshLocks.get(account) === operation) {
|
|
123
|
+
ownedRefreshLocks.delete(account);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
})();
|
|
127
|
+
ownedRefreshLocks.set(account, operation);
|
|
128
|
+
return operation;
|
|
129
|
+
}
|
|
130
|
+
async function _doRefresh(account) {
|
|
131
|
+
try {
|
|
132
|
+
const body = new URLSearchParams({
|
|
133
|
+
grant_type: "refresh_token",
|
|
134
|
+
refresh_token: account.tokens.refreshToken,
|
|
135
|
+
client_id: CLAUDE_CODE_CLIENT_ID,
|
|
136
|
+
});
|
|
137
|
+
const res = await fetch(TOKEN_ENDPOINT, {
|
|
138
|
+
method: "POST",
|
|
139
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
140
|
+
body: body.toString(),
|
|
141
|
+
});
|
|
142
|
+
if (!res.ok) {
|
|
143
|
+
const body = await res.text();
|
|
144
|
+
logRefresh(account.id, false);
|
|
145
|
+
console.error(` Status: ${res.status} — ${body}`);
|
|
146
|
+
account.consecutiveErrors++;
|
|
147
|
+
account.healthy = false;
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
const data = await res.json();
|
|
151
|
+
// CRITICAL: refresh_token ROTATES — save the new one immediately or lose access permanently
|
|
152
|
+
account.tokens.accessToken = data.access_token;
|
|
153
|
+
account.tokens.refreshToken = data.refresh_token;
|
|
154
|
+
account.tokens.expiresAt = Date.now() + data.expires_in * 1000;
|
|
155
|
+
account.tokens.scopes = data.scope.split(" ");
|
|
156
|
+
account.healthy = true;
|
|
157
|
+
account.consecutiveErrors = 0;
|
|
158
|
+
account.lastRefresh = Date.now();
|
|
159
|
+
stats.totalRefreshes++;
|
|
160
|
+
stats.addLog({ ts: Date.now(), accountId: account.id, model: "-", type: "refresh" });
|
|
161
|
+
const expiresInMin = Math.round(data.expires_in / 60);
|
|
162
|
+
logRefresh(account.id, true, expiresInMin);
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
logRefresh(account.id, false);
|
|
167
|
+
console.error(` Error:`, err);
|
|
168
|
+
account.consecutiveErrors++;
|
|
169
|
+
account.healthy = false;
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Persist all accounts to disk.
|
|
175
|
+
* Uses atomic write (tmp + rename) to prevent corruption if process dies mid-write.
|
|
176
|
+
* Must be called after every successful refresh since refresh_token ROTATES.
|
|
177
|
+
*/
|
|
178
|
+
export function saveAccounts(accounts) {
|
|
179
|
+
writeAnthropicAccountsPreservingOtherProviders(serialize(accounts));
|
|
180
|
+
}
|
|
181
|
+
/** Run one ownership-aware scheduled refresh pass. */
|
|
182
|
+
export async function refreshAccountsOnce(accounts, options = {}) {
|
|
183
|
+
const ownershipView = {
|
|
184
|
+
findById: id => accounts.find(account => account.id === id) ?? null,
|
|
185
|
+
getAll: () => accounts,
|
|
186
|
+
};
|
|
187
|
+
for (const account of [...accounts]) {
|
|
188
|
+
if (!needsRefresh(account))
|
|
189
|
+
continue;
|
|
190
|
+
try {
|
|
191
|
+
await refreshAccountIfCurrent(account, ownershipView, {
|
|
192
|
+
persist: options.persist,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
(options.onError ?? console.error)(error);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Background refresh loop: checks every 5 minutes and refreshes any
|
|
202
|
+
* token expiring within the REFRESH_BUFFER_MS window.
|
|
203
|
+
*/
|
|
204
|
+
export function startRefreshLoop(accounts) {
|
|
205
|
+
const check = () => refreshAccountsOnce(accounts);
|
|
206
|
+
// Run immediately on startup (catches already-expired tokens)
|
|
207
|
+
check().catch(console.error);
|
|
208
|
+
setInterval(() => { check().catch(console.error); }, CHECK_INTERVAL_MS);
|
|
209
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const DEFAULT_RATE_LIMITS = {
|
|
2
|
+
status: "unknown",
|
|
3
|
+
fiveHourUtil: 0,
|
|
4
|
+
fiveHourReset: 0,
|
|
5
|
+
sevenDayUtil: 0,
|
|
6
|
+
sevenDayReset: 0,
|
|
7
|
+
claim: "",
|
|
8
|
+
plan: "",
|
|
9
|
+
requestsLimit: 0,
|
|
10
|
+
lastUpdated: 0,
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Single source of truth for the default values of user-controllable
|
|
14
|
+
* account fields. Used by deserialize(), TokenPool.addAccount(),
|
|
15
|
+
* setupSingleAccount(), and the PATCH validation path.
|
|
16
|
+
*/
|
|
17
|
+
export const ACCOUNT_USER_DEFAULTS = {
|
|
18
|
+
enabled: true,
|
|
19
|
+
sessionLimitPercent: 100,
|
|
20
|
+
weeklyLimitPercent: 100,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Coerce any unknown value into a valid percent in [0, 100].
|
|
24
|
+
* Non-numbers, NaN, and out-of-range values collapse to the fallback (100).
|
|
25
|
+
*/
|
|
26
|
+
export function clampPercent(n) {
|
|
27
|
+
const v = typeof n === "number" && Number.isFinite(n) ? n : 100;
|
|
28
|
+
return Math.max(0, Math.min(100, Math.round(v)));
|
|
29
|
+
}
|