@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.
Files changed (74) hide show
  1. package/CHANGELOG.md +96 -0
  2. package/Dockerfile +42 -0
  3. package/LICENSE +21 -0
  4. package/README.md +716 -0
  5. package/accounts.example.json +25 -0
  6. package/dist/cli/cmd-accounts.js +248 -0
  7. package/dist/cli/cmd-client.js +612 -0
  8. package/dist/cli/cmd-configure.js +145 -0
  9. package/dist/cli/cmd-docker.js +140 -0
  10. package/dist/cli/cmd-logs.js +85 -0
  11. package/dist/cli/cmd-models.js +125 -0
  12. package/dist/cli/cmd-service.js +193 -0
  13. package/dist/cli/cmd-setup.js +501 -0
  14. package/dist/cli/cmd-start.js +318 -0
  15. package/dist/cli/cmd-status.js +177 -0
  16. package/dist/cli/cmd-stop.js +100 -0
  17. package/dist/cli/cmd-telemetry.js +58 -0
  18. package/dist/cli/cmd-update.js +37 -0
  19. package/dist/cli/index.js +59 -0
  20. package/dist/config/manager.js +262 -0
  21. package/dist/config/paths.js +21 -0
  22. package/dist/config/telemetry.js +64 -0
  23. package/dist/daemon/launcher.js +163 -0
  24. package/dist/daemon/pid.js +98 -0
  25. package/dist/daemon/service.js +260 -0
  26. package/dist/interceptor/mitmproxy-manager.js +616 -0
  27. package/dist/protocol/anthropic-to-openai.js +51 -0
  28. package/dist/protocol/anthropic-types.js +1 -0
  29. package/dist/protocol/model-ref.js +36 -0
  30. package/dist/protocol/model-routing-config.js +30 -0
  31. package/dist/protocol/openai-response-to-anthropic.js +20 -0
  32. package/dist/protocol/openai-responses-types.js +1 -0
  33. package/dist/protocol/openai-stream-to-anthropic.js +75 -0
  34. package/dist/protocol/openai-to-anthropic.js +61 -0
  35. package/dist/protocol/sse.js +17 -0
  36. package/dist/providers/model-discovery.js +71 -0
  37. package/dist/providers/openai/account-pool.js +11 -0
  38. package/dist/providers/openai/account-record.js +33 -0
  39. package/dist/providers/openai/codex-transport.js +36 -0
  40. package/dist/providers/openai/device-oauth.js +116 -0
  41. package/dist/providers/openai/token-refresher.js +56 -0
  42. package/dist/providers/route-selector.js +8 -0
  43. package/dist/providers/types.js +1 -0
  44. package/dist/proxy/account-deletion.js +44 -0
  45. package/dist/proxy/anthropic-proxy.js +26 -0
  46. package/dist/proxy/anthropic-routing.js +90 -0
  47. package/dist/proxy/lease-lifecycle.js +68 -0
  48. package/dist/proxy/logger.js +39 -0
  49. package/dist/proxy/messages-cross-route.js +179 -0
  50. package/dist/proxy/models-server.js +150 -0
  51. package/dist/proxy/provider-routing.js +14 -0
  52. package/dist/proxy/responses-server.js +91 -0
  53. package/dist/proxy/server.js +875 -0
  54. package/dist/proxy/session-router.js +171 -0
  55. package/dist/proxy/stats.js +25 -0
  56. package/dist/proxy/stream-lifecycle.js +83 -0
  57. package/dist/proxy/token-pool.js +407 -0
  58. package/dist/proxy/token-refresher.js +209 -0
  59. package/dist/proxy/types.js +29 -0
  60. package/dist/ui/Dashboard.js +640 -0
  61. package/dist/ui/accountsApi.js +48 -0
  62. package/dist/ui/modelsApi.js +47 -0
  63. package/dist/utils/claude-config.js +185 -0
  64. package/dist/utils/codex-config.js +62 -0
  65. package/dist/utils/network.js +16 -0
  66. package/dist/utils/platform.js +13 -0
  67. package/dist/utils/self-update.js +239 -0
  68. package/dist/utils/telemetry.js +88 -0
  69. package/dist/utils/token-extractor.js +95 -0
  70. package/dist/utils/token-validator.js +26 -0
  71. package/docker-compose.yml +63 -0
  72. package/litellm-config.yaml +44 -0
  73. package/package.json +69 -0
  74. package/src/interceptor/addon.py +78 -0
@@ -0,0 +1,36 @@
1
+ const CLAUDE_ALIASES = {
2
+ "claude/sonnet": "claude-sonnet-4-5",
3
+ "claude/opus": "claude-opus-4-1",
4
+ };
5
+ function cleanModel(model) {
6
+ const trimmed = model?.trim();
7
+ return trimmed ? trimmed : undefined;
8
+ }
9
+ export function parseModelRef(model, config = {}) {
10
+ const publicModel = cleanModel(model) ?? cleanModel(config.anthropicDefaultModel) ?? "claude/sonnet";
11
+ if (publicModel.startsWith("openai/")) {
12
+ const openAIModel = publicModel.slice("openai/".length);
13
+ const defaultOpenAIModel = cleanModel(config.openAIDefaultModel);
14
+ return {
15
+ provider: "openai_subscription",
16
+ publicModel,
17
+ upstreamModel: config.openAIAliases?.[openAIModel]
18
+ ?? (openAIModel === "default" && defaultOpenAIModel ? defaultOpenAIModel : openAIModel),
19
+ };
20
+ }
21
+ if (publicModel.startsWith("anthropic/")) {
22
+ const anthropicModel = publicModel.slice("anthropic/".length);
23
+ return {
24
+ provider: "anthropic_subscription",
25
+ publicModel,
26
+ upstreamModel: config.anthropicAliases?.[publicModel]
27
+ ?? config.anthropicAliases?.[anthropicModel]
28
+ ?? anthropicModel,
29
+ };
30
+ }
31
+ return {
32
+ provider: "anthropic_subscription",
33
+ publicModel,
34
+ upstreamModel: config.anthropicAliases?.[publicModel] ?? CLAUDE_ALIASES[publicModel] ?? publicModel,
35
+ };
36
+ }
@@ -0,0 +1,30 @@
1
+ function cleanModel(model) {
2
+ const trimmed = model?.trim();
3
+ return trimmed ? trimmed : undefined;
4
+ }
5
+ export function buildModelRoutingUpdate(existing, opts) {
6
+ const next = {
7
+ ...existing,
8
+ anthropicAliases: { ...(existing?.anthropicAliases ?? {}) },
9
+ openAIAliases: { ...(existing?.openAIAliases ?? {}) },
10
+ };
11
+ const claudeModel = cleanModel(opts.claudeModel)?.replace(/^anthropic\//, "");
12
+ if (claudeModel) {
13
+ next.anthropicDefaultModel = claudeModel;
14
+ next.anthropicAliases = {
15
+ ...next.anthropicAliases,
16
+ "claude/sonnet": claudeModel,
17
+ sonnet: claudeModel,
18
+ };
19
+ }
20
+ const openAIModel = cleanModel(opts.openAIModel)?.replace(/^openai\//, "");
21
+ if (openAIModel) {
22
+ next.openAIDefaultModel = openAIModel;
23
+ next.openAIAliases = {
24
+ ...next.openAIAliases,
25
+ default: openAIModel,
26
+ codex: openAIModel,
27
+ };
28
+ }
29
+ return next;
30
+ }
@@ -0,0 +1,20 @@
1
+ export function openAIResponseToAnthropicMessage(response) {
2
+ const content = (response.output ?? [])
3
+ .filter(item => item.type === "message")
4
+ .flatMap(item => item.content)
5
+ .filter(item => item.type === "output_text")
6
+ .map(item => ({ type: "text", text: item.text }));
7
+ return {
8
+ id: response.id,
9
+ type: "message",
10
+ role: "assistant",
11
+ model: response.model ?? "",
12
+ content,
13
+ stop_reason: "end_turn",
14
+ stop_sequence: null,
15
+ usage: {
16
+ input_tokens: response.usage?.input_tokens ?? 0,
17
+ output_tokens: response.usage?.output_tokens ?? 0,
18
+ },
19
+ };
20
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,75 @@
1
+ export function createOpenAIStreamToAnthropicNormalizer() {
2
+ let textBlockStarted = false;
3
+ const ensureTextBlockStarted = () => {
4
+ if (textBlockStarted)
5
+ return [];
6
+ textBlockStarted = true;
7
+ return [
8
+ {
9
+ type: "content_block_start",
10
+ index: 0,
11
+ content_block: { type: "text", text: "" },
12
+ },
13
+ ];
14
+ };
15
+ const reset = () => {
16
+ textBlockStarted = false;
17
+ };
18
+ return {
19
+ reset,
20
+ convert(event) {
21
+ if (event.type === "response.created") {
22
+ reset();
23
+ return [
24
+ {
25
+ type: "message_start",
26
+ message: {
27
+ id: event.response?.id ?? "",
28
+ type: "message",
29
+ role: "assistant",
30
+ model: event.response?.model ?? "",
31
+ content: [],
32
+ stop_reason: null,
33
+ stop_sequence: null,
34
+ usage: { input_tokens: 0, output_tokens: 0 },
35
+ },
36
+ },
37
+ ];
38
+ }
39
+ if (event.type === "response.output_text.delta") {
40
+ return [
41
+ ...ensureTextBlockStarted(),
42
+ {
43
+ type: "content_block_delta",
44
+ index: 0,
45
+ delta: { type: "text_delta", text: event.delta ?? "" },
46
+ },
47
+ ];
48
+ }
49
+ if (event.type === "response.completed") {
50
+ const usage = event.response?.usage ?? {};
51
+ const prefix = textBlockStarted
52
+ ? [{ type: "content_block_stop", index: 0 }]
53
+ : [];
54
+ reset();
55
+ return [
56
+ ...prefix,
57
+ {
58
+ type: "message_delta",
59
+ delta: { stop_reason: "end_turn", stop_sequence: null },
60
+ usage: { output_tokens: usage.output_tokens ?? 0 },
61
+ },
62
+ { type: "message_stop" },
63
+ ];
64
+ }
65
+ return [];
66
+ },
67
+ };
68
+ }
69
+ const defaultNormalizer = createOpenAIStreamToAnthropicNormalizer();
70
+ export function resetOpenAIStreamNormalizer() {
71
+ defaultNormalizer.reset();
72
+ }
73
+ export function openAIStreamEventToAnthropicEvents(event) {
74
+ return defaultNormalizer.convert(event);
75
+ }
@@ -0,0 +1,61 @@
1
+ import { parseModelRef } from "./model-ref.js";
2
+ function parseArguments(args) {
3
+ try {
4
+ return JSON.parse(args);
5
+ }
6
+ catch {
7
+ return { value: args };
8
+ }
9
+ }
10
+ function textFromOpenAI(block) {
11
+ if (block.type === "input_text" || block.type === "output_text")
12
+ return block.text;
13
+ return null;
14
+ }
15
+ function messageContentToAnthropic(message) {
16
+ const blocks = message.content.map((block) => {
17
+ const text = textFromOpenAI(block);
18
+ if (text !== null)
19
+ return { type: "text", text };
20
+ if (block.type === "function_call") {
21
+ return {
22
+ type: "tool_use",
23
+ id: block.call_id,
24
+ name: block.name,
25
+ input: parseArguments(block.arguments),
26
+ };
27
+ }
28
+ if (block.type === "function_call_output") {
29
+ return {
30
+ type: "tool_result",
31
+ tool_use_id: block.call_id,
32
+ content: block.output,
33
+ };
34
+ }
35
+ return null;
36
+ }).filter((block) => block !== null);
37
+ if (blocks.length === 1 && blocks[0].type === "text")
38
+ return blocks[0].text;
39
+ return blocks;
40
+ }
41
+ function normalizeRole(role) {
42
+ return role === "assistant" ? "assistant" : "user";
43
+ }
44
+ export function openAIResponsesToAnthropic(req) {
45
+ const parsed = parseModelRef(req.model);
46
+ return {
47
+ model: parsed.upstreamModel,
48
+ system: req.instructions,
49
+ messages: req.input.map(message => ({
50
+ role: normalizeRole(message.role),
51
+ content: messageContentToAnthropic(message),
52
+ })),
53
+ tools: req.tools?.map(tool => ({
54
+ name: tool.name,
55
+ description: tool.description,
56
+ input_schema: tool.parameters,
57
+ })),
58
+ max_tokens: req.max_output_tokens,
59
+ stream: req.stream,
60
+ };
61
+ }
@@ -0,0 +1,17 @@
1
+ export function parseSseLines(input) {
2
+ const lines = input.split("\n");
3
+ const remainder = lines.pop() ?? "";
4
+ const events = [];
5
+ for (const line of lines) {
6
+ if (!line.startsWith("data: "))
7
+ continue;
8
+ const payload = line.slice(6).trim();
9
+ if (!payload || payload === "[DONE]")
10
+ continue;
11
+ events.push(JSON.parse(payload));
12
+ }
13
+ return { events, remainder };
14
+ }
15
+ export function encodeSseEvent(event) {
16
+ return `data: ${JSON.stringify(event)}\n\n`;
17
+ }
@@ -0,0 +1,71 @@
1
+ export const ANTHROPIC_MODELS_ENDPOINT = "https://api.anthropic.com/v1/models";
2
+ export const OPENAI_CODEX_MODELS_ENDPOINT = "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0";
3
+ export const MODEL_DISCOVERY_TIMEOUT_MS = 3_000;
4
+ export function normalizeModelIds(payload) {
5
+ const values = getModelValues(payload);
6
+ const ids = new Set();
7
+ for (const value of values) {
8
+ const id = getModelId(value);
9
+ if (id)
10
+ ids.add(id);
11
+ }
12
+ return [...ids];
13
+ }
14
+ export async function fetchAnthropicModels(account, fetchImpl = fetch) {
15
+ return fetchModels(ANTHROPIC_MODELS_ENDPOINT, {
16
+ method: "GET",
17
+ headers: {
18
+ authorization: `Bearer ${account.tokens.accessToken}`,
19
+ "anthropic-version": "2023-06-01",
20
+ "anthropic-beta": "oauth-2025-04-20",
21
+ },
22
+ signal: AbortSignal.timeout(MODEL_DISCOVERY_TIMEOUT_MS),
23
+ }, fetchImpl);
24
+ }
25
+ export async function fetchOpenAICodexModels(account, fetchImpl = fetch) {
26
+ return fetchModels(OPENAI_CODEX_MODELS_ENDPOINT, {
27
+ method: "GET",
28
+ headers: {
29
+ authorization: `Bearer ${account.accessToken}`,
30
+ accept: "application/json",
31
+ },
32
+ signal: AbortSignal.timeout(MODEL_DISCOVERY_TIMEOUT_MS),
33
+ }, fetchImpl);
34
+ }
35
+ async function fetchModels(url, init, fetchImpl) {
36
+ try {
37
+ const res = await fetchImpl(url, init);
38
+ if (!res.ok)
39
+ return [];
40
+ return normalizeModelIds(await res.json());
41
+ }
42
+ catch {
43
+ return [];
44
+ }
45
+ }
46
+ function getModelValues(payload) {
47
+ if (Array.isArray(payload))
48
+ return payload;
49
+ if (!payload || typeof payload !== "object")
50
+ return [];
51
+ const record = payload;
52
+ if (Array.isArray(record.data))
53
+ return record.data;
54
+ if (Array.isArray(record.models))
55
+ return record.models;
56
+ return [];
57
+ }
58
+ function getModelId(value) {
59
+ if (typeof value === "string")
60
+ return normalizeId(value);
61
+ if (!value || typeof value !== "object")
62
+ return undefined;
63
+ const record = value;
64
+ return normalizeId(record.id) ?? normalizeId(record.slug) ?? normalizeId(record.name);
65
+ }
66
+ function normalizeId(value) {
67
+ if (typeof value !== "string")
68
+ return undefined;
69
+ const trimmed = value.trim();
70
+ return trimmed.length > 0 ? trimmed : undefined;
71
+ }
@@ -0,0 +1,11 @@
1
+ export function createOpenAIAccountPicker(accounts) {
2
+ let index = 0;
3
+ return () => {
4
+ const enabled = accounts.filter(account => account.enabled);
5
+ if (enabled.length === 0)
6
+ return null;
7
+ const account = enabled[index % enabled.length];
8
+ index = (index + 1) % enabled.length;
9
+ return account;
10
+ };
11
+ }
@@ -0,0 +1,33 @@
1
+ function parseExpiresAt(value) {
2
+ const parsed = typeof value === "number" ? value : Number(value);
3
+ if (!Number.isFinite(parsed) || parsed <= 0) {
4
+ throw new Error("expiresAt must be a positive Unix timestamp in milliseconds");
5
+ }
6
+ return parsed;
7
+ }
8
+ function parseScopes(value) {
9
+ if (Array.isArray(value))
10
+ return value.filter(Boolean);
11
+ if (typeof value === "string")
12
+ return value.split(/\s+/).filter(Boolean);
13
+ return ["openid", "profile", "email", "offline_access"];
14
+ }
15
+ export function createOpenAIAccountRecord(input) {
16
+ const id = input.id.trim();
17
+ if (!/^[a-zA-Z0-9_-]+$/.test(id)) {
18
+ throw new Error("Only letters, numbers, _ and - allowed in account ID");
19
+ }
20
+ if (!input.accessToken.trim())
21
+ throw new Error("Access token is required");
22
+ if (!input.refreshToken.trim())
23
+ throw new Error("Refresh token is required");
24
+ return {
25
+ id,
26
+ provider: "openai_subscription",
27
+ accessToken: input.accessToken.trim(),
28
+ refreshToken: input.refreshToken.trim(),
29
+ expiresAt: parseExpiresAt(input.expiresAt),
30
+ scopes: parseScopes(input.scopes),
31
+ enabled: input.enabled ?? true,
32
+ };
33
+ }
@@ -0,0 +1,36 @@
1
+ const CODEX_RESPONSES_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
2
+ const DEFAULT_CODEX_INSTRUCTIONS = "You are a concise coding assistant.";
3
+ export async function forwardOpenAICodexResponse(opts) {
4
+ const body = toCodexBackendRequest(opts.body);
5
+ const upstream = await fetch(CODEX_RESPONSES_ENDPOINT, {
6
+ method: "POST",
7
+ headers: {
8
+ authorization: `Bearer ${opts.account.accessToken}`,
9
+ "content-type": "application/json",
10
+ accept: "text/event-stream",
11
+ },
12
+ body: JSON.stringify(body),
13
+ });
14
+ return ensureEventStreamContentType(upstream);
15
+ }
16
+ export function toCodexBackendRequest(body) {
17
+ const { max_output_tokens: _maxOutputTokens, ...rest } = body;
18
+ return {
19
+ ...rest,
20
+ instructions: body.instructions?.trim() || DEFAULT_CODEX_INSTRUCTIONS,
21
+ store: false,
22
+ stream: true,
23
+ };
24
+ }
25
+ function ensureEventStreamContentType(upstream) {
26
+ const contentType = upstream.headers.get("content-type");
27
+ if (contentType?.includes("text/event-stream"))
28
+ return upstream;
29
+ const headers = new Headers(upstream.headers);
30
+ headers.set("content-type", "text/event-stream");
31
+ return new Response(upstream.body, {
32
+ status: upstream.status,
33
+ statusText: upstream.statusText,
34
+ headers,
35
+ });
36
+ }
@@ -0,0 +1,116 @@
1
+ import { createOpenAIAccountRecord } from "./account-record.js";
2
+ const DEFAULT_ISSUER = "https://auth.openai.com";
3
+ const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
4
+ const DEFAULT_SCOPE = "openid profile email offline_access";
5
+ const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
6
+ function issuerOf(opts) {
7
+ return (opts.issuer ?? DEFAULT_ISSUER).replace(/\/+$/, "");
8
+ }
9
+ function clientIdOf(opts) {
10
+ return opts.clientId ?? DEFAULT_CLIENT_ID;
11
+ }
12
+ function fetchOf(opts) {
13
+ return opts.fetchImpl ?? fetch;
14
+ }
15
+ function parseAccessTokenExpiry(accessToken) {
16
+ const [, payload] = accessToken.split(".");
17
+ if (!payload)
18
+ throw new Error("OpenAI access token is not a JWT");
19
+ const claims = JSON.parse(Buffer.from(payload, "base64url").toString("utf-8"));
20
+ if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) {
21
+ throw new Error("OpenAI access token JWT does not contain a numeric exp claim");
22
+ }
23
+ return claims.exp * 1000;
24
+ }
25
+ async function readError(res) {
26
+ try {
27
+ return await res.text();
28
+ }
29
+ catch {
30
+ return "";
31
+ }
32
+ }
33
+ export async function requestOpenAIDeviceCode(opts = {}) {
34
+ const issuer = issuerOf(opts);
35
+ const res = await fetchOf(opts)(`${issuer}/api/accounts/deviceauth/usercode`, {
36
+ method: "POST",
37
+ headers: { "Content-Type": "application/json" },
38
+ body: JSON.stringify({ client_id: clientIdOf(opts) }),
39
+ });
40
+ if (!res.ok) {
41
+ throw new Error(`OpenAI device code request failed (${res.status}): ${await readError(res)}`);
42
+ }
43
+ const body = await res.json();
44
+ const userCode = body.user_code ?? body.usercode;
45
+ if (!body.device_auth_id || !userCode) {
46
+ throw new Error("OpenAI device code response is missing device_auth_id or user_code");
47
+ }
48
+ return {
49
+ verificationUrl: `${issuer}/codex/device`,
50
+ userCode,
51
+ deviceAuthId: body.device_auth_id,
52
+ intervalSeconds: Number(body.interval ?? 5),
53
+ };
54
+ }
55
+ async function pollAuthorizationCode(opts) {
56
+ const issuer = issuerOf(opts);
57
+ const sleep = opts.sleep ?? ((ms) => new Promise(resolve => setTimeout(resolve, ms)));
58
+ const now = opts.now ?? (() => Date.now());
59
+ const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60
+ const started = now();
61
+ while (now() - started <= timeoutMs) {
62
+ const res = await fetchOf(opts)(`${issuer}/api/accounts/deviceauth/token`, {
63
+ method: "POST",
64
+ headers: { "Content-Type": "application/json" },
65
+ body: JSON.stringify({
66
+ device_auth_id: opts.deviceCode.deviceAuthId,
67
+ user_code: opts.deviceCode.userCode,
68
+ }),
69
+ });
70
+ if (res.ok)
71
+ return await res.json();
72
+ if (res.status !== 403 && res.status !== 404) {
73
+ throw new Error(`OpenAI device authorization failed (${res.status}): ${await readError(res)}`);
74
+ }
75
+ await sleep(Math.max(1, opts.deviceCode.intervalSeconds) * 1000);
76
+ }
77
+ throw new Error("OpenAI device authorization timed out");
78
+ }
79
+ export async function exchangeOpenAIDeviceCodeForTokens(opts) {
80
+ const issuer = issuerOf(opts);
81
+ const code = await pollAuthorizationCode(opts);
82
+ const form = new URLSearchParams({
83
+ grant_type: "authorization_code",
84
+ code: code.authorization_code,
85
+ redirect_uri: `${issuer}/deviceauth/callback`,
86
+ client_id: clientIdOf(opts),
87
+ code_verifier: code.code_verifier,
88
+ });
89
+ const res = await fetchOf(opts)(`${issuer}/oauth/token`, {
90
+ method: "POST",
91
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
92
+ body: form.toString(),
93
+ });
94
+ if (!res.ok) {
95
+ throw new Error(`OpenAI token exchange failed (${res.status}): ${await readError(res)}`);
96
+ }
97
+ const tokens = await res.json();
98
+ return {
99
+ idToken: tokens.id_token,
100
+ accessToken: tokens.access_token,
101
+ refreshToken: tokens.refresh_token,
102
+ expiresAt: parseAccessTokenExpiry(tokens.access_token),
103
+ };
104
+ }
105
+ export async function loginOpenAIWithDeviceCode(opts) {
106
+ const deviceCode = await requestOpenAIDeviceCode(opts);
107
+ opts.onDeviceCode?.(deviceCode);
108
+ const tokens = await exchangeOpenAIDeviceCodeForTokens({ ...opts, deviceCode });
109
+ return createOpenAIAccountRecord({
110
+ id: opts.accountId,
111
+ accessToken: tokens.accessToken,
112
+ refreshToken: tokens.refreshToken,
113
+ expiresAt: tokens.expiresAt,
114
+ scopes: DEFAULT_SCOPE,
115
+ });
116
+ }
@@ -0,0 +1,56 @@
1
+ const TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
2
+ const REFRESH_BUFFER_MS = 10 * 60 * 1000;
3
+ const CHECK_INTERVAL_MS = 5 * 60 * 1000;
4
+ const refreshLocks = new Map();
5
+ export function needsOpenAIRefresh(account) {
6
+ return account.expiresAt - Date.now() < REFRESH_BUFFER_MS;
7
+ }
8
+ export async function refreshOpenAISubscriptionToken(account) {
9
+ const existing = refreshLocks.get(account.id);
10
+ if (existing)
11
+ return existing;
12
+ const promise = doRefresh(account);
13
+ refreshLocks.set(account.id, promise);
14
+ try {
15
+ return await promise;
16
+ }
17
+ finally {
18
+ refreshLocks.delete(account.id);
19
+ }
20
+ }
21
+ export async function prepareOpenAIAccountForRequest(account, allAccounts, saveAccounts) {
22
+ if (!needsOpenAIRefresh(account))
23
+ return true;
24
+ const ok = await refreshOpenAISubscriptionToken(account);
25
+ if (ok)
26
+ saveAccounts(allAccounts);
27
+ return ok;
28
+ }
29
+ export function startOpenAIRefreshLoop(accounts, saveAccounts) {
30
+ const check = async () => {
31
+ for (const account of accounts) {
32
+ await prepareOpenAIAccountForRequest(account, accounts, saveAccounts);
33
+ }
34
+ };
35
+ const timer = setInterval(() => { check().catch(console.error); }, CHECK_INTERVAL_MS);
36
+ queueMicrotask(() => { check().catch(console.error); });
37
+ return () => clearInterval(timer);
38
+ }
39
+ async function doRefresh(account) {
40
+ const body = new URLSearchParams({
41
+ grant_type: "refresh_token",
42
+ refresh_token: account.refreshToken,
43
+ });
44
+ const res = await fetch(TOKEN_ENDPOINT, {
45
+ method: "POST",
46
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
47
+ body: body.toString(),
48
+ });
49
+ if (!res.ok)
50
+ return false;
51
+ const data = await res.json();
52
+ account.accessToken = data.access_token;
53
+ account.refreshToken = data.refresh_token ?? account.refreshToken;
54
+ account.expiresAt = Date.now() + data.expires_in * 1000;
55
+ return true;
56
+ }
@@ -0,0 +1,8 @@
1
+ import { parseModelRef } from "../protocol/model-ref.js";
2
+ export function selectRoute(model, config = {}) {
3
+ const parsed = parseModelRef(model, config);
4
+ return {
5
+ ...parsed,
6
+ ingressProtocol: parsed.provider === "openai_subscription" ? "responses" : "anthropic_messages",
7
+ };
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,44 @@
1
+ import { reserveAccountForDeletion } from "./token-refresher.js";
2
+ export class AccountDeletionConflictError extends Error {
3
+ constructor(id) {
4
+ super(`Account "${id}" changed during deletion`);
5
+ this.name = "AccountDeletionConflictError";
6
+ }
7
+ }
8
+ export class LastAccountDeletionError extends Error {
9
+ constructor() {
10
+ super("Cannot remove the last account — at least one must remain");
11
+ this.name = "LastAccountDeletionError";
12
+ }
13
+ }
14
+ export function accountDeletionStatusCode(error) {
15
+ return error instanceof AccountDeletionConflictError ||
16
+ error instanceof LastAccountDeletionError
17
+ ? 409
18
+ : 500;
19
+ }
20
+ /** Persist prospective state before irreversibly removing runtime routing state. */
21
+ export async function deleteAnthropicAccountTransaction(options) {
22
+ const account = options.pool.findById(options.id);
23
+ if (!account)
24
+ throw new Error(`Account "${options.id}" not found`);
25
+ const releaseReservation = await reserveAccountForDeletion(account);
26
+ try {
27
+ if (options.pool.findById(options.id) !== account) {
28
+ throw new AccountDeletionConflictError(options.id);
29
+ }
30
+ if (options.pool.getAll().length <= 1) {
31
+ throw new LastAccountDeletionError();
32
+ }
33
+ const prospective = options.pool.getAll().filter(candidate => candidate !== account);
34
+ options.persist(prospective);
35
+ if (!options.pool.removeAccount(options.id)) {
36
+ throw new AccountDeletionConflictError(options.id);
37
+ }
38
+ options.sessionRouter.invalidateAccount(options.id);
39
+ return account;
40
+ }
41
+ finally {
42
+ releaseReservation();
43
+ }
44
+ }
@@ -0,0 +1,26 @@
1
+ import { createProxyMiddleware } from "http-proxy-middleware";
2
+ /**
3
+ * Construct the Anthropic transport with http-proxy-middleware's native
4
+ * response piping. In particular, this deliberately does not self-handle,
5
+ * buffer, transform, or synthesize any response bytes.
6
+ */
7
+ export function createAnthropicProxy(options) {
8
+ const configuredProxyRequest = options.on.proxyReq;
9
+ return createProxyMiddleware({
10
+ target: options.target,
11
+ changeOrigin: true,
12
+ pathRewrite: path => `/v1${path}`,
13
+ proxyTimeout: options.timeoutMs,
14
+ timeout: options.timeoutMs,
15
+ on: {
16
+ ...options.on,
17
+ proxyReq: (proxyRequest, request, response, proxyOptions) => {
18
+ proxyRequest.once("response", () => {
19
+ proxyRequest.setTimeout(0);
20
+ request.socket.setTimeout(0);
21
+ });
22
+ configuredProxyRequest?.(proxyRequest, request, response, proxyOptions);
23
+ },
24
+ },
25
+ });
26
+ }