@kairyou/agent-tools 0.4.0 → 0.5.2
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/README.md +6 -5
- package/README.zh-CN.md +4 -4
- package/dist/statusline/claude-statusline.mjs +866 -61
- package/dist/usage/core.mjs +1199 -377
- package/integrations/statusline/claude-statusline.mjs +6 -65
- package/integrations/usage/core.mjs +13 -1103
- package/integrations/usage/lib/cache.mjs +110 -0
- package/integrations/usage/lib/config.mjs +99 -0
- package/integrations/usage/lib/context.mjs +138 -0
- package/integrations/usage/lib/format.mjs +265 -0
- package/integrations/usage/lib/http.mjs +186 -0
- package/integrations/usage/lib/routes.mjs +265 -0
- package/integrations/usage/lib/urls.mjs +48 -0
- package/package.json +1 -1
- package/scripts/install.mjs +32 -68
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// JSON-over-HTTP requests to gateways, including the anti-bot shield
|
|
2
|
+
// challenge some NewAPI deployments serve before the real response.
|
|
3
|
+
|
|
4
|
+
import { createContext, runInContext } from "node:vm";
|
|
5
|
+
import { debugLog } from "./config.mjs";
|
|
6
|
+
|
|
7
|
+
const REQUEST_TIMEOUT_MS = 5000;
|
|
8
|
+
const SHIELD_USER_AGENT =
|
|
9
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
10
|
+
"(KHTML, like Gecko) Chrome/132.0.0.0 Safari/537.36";
|
|
11
|
+
|
|
12
|
+
function shortPreview(text) {
|
|
13
|
+
return String(text || "")
|
|
14
|
+
.replace(/\s+/g, " ")
|
|
15
|
+
.trim()
|
|
16
|
+
.slice(0, 220);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isShieldChallenge(contentType, text) {
|
|
20
|
+
const normalizedType = String(contentType || "").toLowerCase();
|
|
21
|
+
return (
|
|
22
|
+
(normalizedType.includes("text/html") && /var\s+arg1\s*=|acw_sc__v2|cdn_sec_tc|<script/i.test(text)) ||
|
|
23
|
+
/var\s+arg1\s*=/.test(text)
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function parseChallengeArg1(html) {
|
|
28
|
+
const match = String(html).match(/var\s+arg1\s*=\s*['"]([0-9a-fA-F]+)['"]/);
|
|
29
|
+
return match?.[1]?.toUpperCase() || "";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function parseChallengeMapping(html) {
|
|
33
|
+
const match = String(html).match(/for\(var m=\[([^\]]+)\],p=L\(0x115\)/);
|
|
34
|
+
if (!match?.[1]) return null;
|
|
35
|
+
const values = match[1].split(",").map((raw) => {
|
|
36
|
+
const value = raw.trim().toLowerCase();
|
|
37
|
+
if (!value) return Number.NaN;
|
|
38
|
+
return value.startsWith("0x") ? Number.parseInt(value.slice(2), 16) : Number.parseInt(value, 10);
|
|
39
|
+
});
|
|
40
|
+
return values.some((value) => Number.isNaN(value)) ? null : values;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function parseChallengeXorSeed(html) {
|
|
44
|
+
const text = String(html);
|
|
45
|
+
const fnStart = text.indexOf("function a0i()");
|
|
46
|
+
const bStart = text.indexOf("function b(");
|
|
47
|
+
const rotateStart = text.indexOf("(function(a,c){");
|
|
48
|
+
const rotateEnd = text.indexOf("),!(function", rotateStart);
|
|
49
|
+
if (fnStart < 0 || bStart < 0 || bStart <= fnStart || rotateStart < 0 || rotateEnd < 0) {
|
|
50
|
+
return "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const helperCode = text.slice(fnStart, bStart);
|
|
54
|
+
const rotateCode = `${text.slice(rotateStart, rotateEnd + 1)})`;
|
|
55
|
+
try {
|
|
56
|
+
const sandbox = { decodeURIComponent };
|
|
57
|
+
createContext(sandbox);
|
|
58
|
+
runInContext(helperCode, sandbox, { timeout: 100 });
|
|
59
|
+
runInContext(rotateCode, sandbox, { timeout: 100 });
|
|
60
|
+
const decoder = sandbox.a0j;
|
|
61
|
+
if (typeof decoder !== "function") return "";
|
|
62
|
+
const seed = decoder(0x115);
|
|
63
|
+
return typeof seed === "string" && /^[0-9a-f]+$/i.test(seed) ? seed : "";
|
|
64
|
+
} catch {
|
|
65
|
+
return "";
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function solveNewApiAcwScV2(html) {
|
|
70
|
+
const arg1 = parseChallengeArg1(html);
|
|
71
|
+
const mapping = parseChallengeMapping(html);
|
|
72
|
+
const xorSeed = parseChallengeXorSeed(html);
|
|
73
|
+
if (!arg1 || !mapping || !xorSeed) return "";
|
|
74
|
+
|
|
75
|
+
const reordered = [];
|
|
76
|
+
for (let i = 0; i < arg1.length; i += 1) {
|
|
77
|
+
const ch = arg1[i];
|
|
78
|
+
for (let j = 0; j < mapping.length; j += 1) {
|
|
79
|
+
if (mapping[j] === i + 1) reordered[j] = ch;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const source = reordered.join("");
|
|
84
|
+
let out = "";
|
|
85
|
+
for (let i = 0; i < source.length && i < xorSeed.length; i += 2) {
|
|
86
|
+
const left = Number.parseInt(source.slice(i, i + 2), 16);
|
|
87
|
+
const right = Number.parseInt(xorSeed.slice(i, i + 2), 16);
|
|
88
|
+
if (Number.isNaN(left) || Number.isNaN(right)) return "";
|
|
89
|
+
out += (left ^ right).toString(16).padStart(2, "0");
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function upsertCookie(cookieHeader, name, value) {
|
|
95
|
+
const parts = String(cookieHeader || "").split(";").map((part) => part.trim()).filter(Boolean);
|
|
96
|
+
let replaced = false;
|
|
97
|
+
const next = parts.map((part) => {
|
|
98
|
+
const eq = part.indexOf("=");
|
|
99
|
+
if (eq < 0) return part;
|
|
100
|
+
const key = part.slice(0, eq).trim();
|
|
101
|
+
if (key !== name) return part;
|
|
102
|
+
replaced = true;
|
|
103
|
+
return `${name}=${value}`;
|
|
104
|
+
});
|
|
105
|
+
if (!replaced) next.push(`${name}=${value}`);
|
|
106
|
+
return next.join("; ");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function collectSetCookieHeaders(headers) {
|
|
110
|
+
const getSetCookie = headers?.getSetCookie;
|
|
111
|
+
if (typeof getSetCookie === "function") return getSetCookie.call(headers) || [];
|
|
112
|
+
const single = headers?.get?.("set-cookie");
|
|
113
|
+
return single ? [single] : [];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function mergeSetCookiePairs(cookieHeader, setCookieHeaders) {
|
|
117
|
+
let merged = cookieHeader || "";
|
|
118
|
+
for (const raw of setCookieHeaders || []) {
|
|
119
|
+
const firstPair = String(raw || "").split(";")[0]?.trim();
|
|
120
|
+
if (!firstPair) continue;
|
|
121
|
+
const eq = firstPair.indexOf("=");
|
|
122
|
+
if (eq <= 0) continue;
|
|
123
|
+
merged = upsertCookie(merged, firstPair.slice(0, eq).trim(), firstPair.slice(eq + 1));
|
|
124
|
+
}
|
|
125
|
+
return merged;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export async function requestJson(url, key, options = {}) {
|
|
129
|
+
let cookieHeader = "";
|
|
130
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
131
|
+
const controller = new AbortController();
|
|
132
|
+
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
|
133
|
+
const response = await fetch(url, {
|
|
134
|
+
headers: {
|
|
135
|
+
accept: "application/json",
|
|
136
|
+
authorization: `Bearer ${options.authKey || key}`,
|
|
137
|
+
"user-agent": SHIELD_USER_AGENT,
|
|
138
|
+
...(cookieHeader ? { cookie: cookieHeader } : {}),
|
|
139
|
+
...(options.headers || {}),
|
|
140
|
+
},
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
try {
|
|
145
|
+
const body = await response.text();
|
|
146
|
+
cookieHeader = mergeSetCookiePairs(cookieHeader, collectSetCookieHeaders(response.headers));
|
|
147
|
+
let json = {};
|
|
148
|
+
try {
|
|
149
|
+
json = body ? JSON.parse(body) : {};
|
|
150
|
+
} catch {
|
|
151
|
+
const contentType = response.headers.get("content-type") || "";
|
|
152
|
+
const acwScV2 = isShieldChallenge(contentType, body) ? solveNewApiAcwScV2(body) : "";
|
|
153
|
+
await debugLog({
|
|
154
|
+
source: options.name || "usage",
|
|
155
|
+
url,
|
|
156
|
+
status: response.status,
|
|
157
|
+
contentType,
|
|
158
|
+
shieldRetry: Boolean(acwScV2 && attempt === 0),
|
|
159
|
+
bodyPreview: shortPreview(body),
|
|
160
|
+
});
|
|
161
|
+
if (acwScV2 && attempt === 0) {
|
|
162
|
+
cookieHeader = upsertCookie(cookieHeader, "acw_sc__v2", acwScV2);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
throw new Error(`${options.name || "usage"} returned non-JSON (${response.status})`);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (!response.ok) {
|
|
169
|
+
const message = json?.error?.message || json?.message || response.statusText;
|
|
170
|
+
await debugLog({
|
|
171
|
+
source: options.name || "usage",
|
|
172
|
+
url,
|
|
173
|
+
status: response.status,
|
|
174
|
+
message,
|
|
175
|
+
bodyPreview: shortPreview(body),
|
|
176
|
+
});
|
|
177
|
+
throw new Error(`${options.name || "usage"} failed (${response.status} ${message})`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return json;
|
|
181
|
+
} finally {
|
|
182
|
+
clearTimeout(timeout);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
throw new Error(`${options.name || "usage"} unavailable`);
|
|
186
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Known gateway usage endpoints and the probing order for a given context.
|
|
2
|
+
|
|
3
|
+
import { requestJson } from "./http.mjs";
|
|
4
|
+
import {
|
|
5
|
+
usagePreset,
|
|
6
|
+
panelUserHeaders,
|
|
7
|
+
newApiQuotaScale,
|
|
8
|
+
providerUsageDays,
|
|
9
|
+
debugLog,
|
|
10
|
+
} from "./config.mjs";
|
|
11
|
+
import {
|
|
12
|
+
cleanBaseUrl,
|
|
13
|
+
serviceRoot,
|
|
14
|
+
joinUrl,
|
|
15
|
+
hostIncludes,
|
|
16
|
+
usageRouteCacheKey,
|
|
17
|
+
} from "./urls.mjs";
|
|
18
|
+
import {
|
|
19
|
+
pickNumber,
|
|
20
|
+
formatMoney,
|
|
21
|
+
usageRoot,
|
|
22
|
+
hasV1UsageFields,
|
|
23
|
+
formatQuota,
|
|
24
|
+
formatNewApiTokenLine,
|
|
25
|
+
formatOpenRouterLine,
|
|
26
|
+
formatPanelUserSelfLine,
|
|
27
|
+
panelQuotaScale,
|
|
28
|
+
panelQuotaLooksRemaining,
|
|
29
|
+
} from "./format.mjs";
|
|
30
|
+
import { readRouteCache } from "./cache.mjs";
|
|
31
|
+
|
|
32
|
+
async function subscriptionUrl(baseUrl) {
|
|
33
|
+
const clean = baseUrl.replace(/\/+$/, "");
|
|
34
|
+
const url = clean.endsWith("/v1") ? `${clean}/usage` : `${clean}/v1/usage`;
|
|
35
|
+
return `${url}?days=${await providerUsageDays()}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function usageResult(context, source, text, raw) {
|
|
39
|
+
return {
|
|
40
|
+
updatedAt: new Date().toISOString(),
|
|
41
|
+
baseUrl: context.baseUrl,
|
|
42
|
+
provider: context.providerName,
|
|
43
|
+
source,
|
|
44
|
+
text,
|
|
45
|
+
raw,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Sub2API and several private OpenAI-compatible gateways expose a lightweight
|
|
50
|
+
// OpenAI-style endpoint at /v1/usage. This is intentionally probed first for
|
|
51
|
+
// generic non-OpenAI base URLs because it does not require a management token.
|
|
52
|
+
async function fetchV1Usage(context) {
|
|
53
|
+
const json = await requestJson(await subscriptionUrl(context.baseUrl), context.key, {
|
|
54
|
+
name: "v1 usage",
|
|
55
|
+
});
|
|
56
|
+
if (!hasV1UsageFields(usageRoot(json))) throw new Error("v1 usage payload has no usage fields");
|
|
57
|
+
return usageResult(context, "v1-usage", await formatQuota(context.label, json), json);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// NewAPI / OneAPI family panels: use the current API key as Bearer auth and
|
|
61
|
+
// query token usage from the service root rather than the /v1 OpenAI-compatible
|
|
62
|
+
// path.
|
|
63
|
+
async function fetchNewApiTokenUsage(context) {
|
|
64
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/usage/token/"), context.key, {
|
|
65
|
+
name: "NewAPI token usage",
|
|
66
|
+
});
|
|
67
|
+
const root = usageRoot(json);
|
|
68
|
+
const quota = pickNumber(root, ["quota", "limit", "total_quota", "totalQuota"]);
|
|
69
|
+
const used = pickNumber(root, ["used_quota", "usedQuota", "used"]);
|
|
70
|
+
let remaining = pickNumber(root, ["remain_quota", "remainQuota", "remaining", "balance"]);
|
|
71
|
+
if (remaining === undefined && quota !== undefined && used !== undefined) {
|
|
72
|
+
remaining = Math.max(0, quota - used);
|
|
73
|
+
}
|
|
74
|
+
const scale = await newApiQuotaScale();
|
|
75
|
+
const quotaForWarning = scale ? quota / scale : quota;
|
|
76
|
+
const usedForWarning = scale ? used / scale : used;
|
|
77
|
+
const remainingForWarning = scale ? remaining / scale : remaining;
|
|
78
|
+
const normalized = {
|
|
79
|
+
mode: "quota_limited",
|
|
80
|
+
quota: {
|
|
81
|
+
limit: quotaForWarning,
|
|
82
|
+
used: usedForWarning,
|
|
83
|
+
remaining: remainingForWarning,
|
|
84
|
+
},
|
|
85
|
+
unit: scale ? "USD" : "quota",
|
|
86
|
+
source: "newapi-token",
|
|
87
|
+
raw: json,
|
|
88
|
+
};
|
|
89
|
+
return usageResult(context, "newapi-token", await formatNewApiTokenLine(context.label, json), normalized);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// NewAPI / OneAPI / OneHub / DoneHub / Veloera panel session endpoint, based on
|
|
93
|
+
// Metapi's platform handling. This works when PROVIDER_USAGE_API_KEY is a panel
|
|
94
|
+
// access/session token, or when the site accepts the API key for /api/user/self.
|
|
95
|
+
async function fetchPanelUserSelfUsage(context) {
|
|
96
|
+
const preset = await usagePreset();
|
|
97
|
+
const kind = preset === "auto" ? "new-api" : preset;
|
|
98
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/user/self"), context.key, {
|
|
99
|
+
name: "panel /api/user/self",
|
|
100
|
+
headers: await panelUserHeaders(),
|
|
101
|
+
});
|
|
102
|
+
const root = usageRoot(json);
|
|
103
|
+
if (pickNumber(root, ["quota"]) === undefined && pickNumber(root, ["used_quota", "usedQuota"]) === undefined) {
|
|
104
|
+
await debugLog({
|
|
105
|
+
source: "panel /api/user/self",
|
|
106
|
+
payloadKeys: Object.keys(root || {}).slice(0, 20),
|
|
107
|
+
success: root?.success,
|
|
108
|
+
message: root?.message || root?.error?.message || "",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
const scale = panelQuotaScale(kind);
|
|
112
|
+
const quota = pickNumber(root, ["quota"]);
|
|
113
|
+
const used = pickNumber(root, ["used_quota", "usedQuota"]);
|
|
114
|
+
const remaining = panelQuotaLooksRemaining(kind)
|
|
115
|
+
? quota
|
|
116
|
+
: (quota === undefined || used === undefined ? quota : Math.max(0, quota - used));
|
|
117
|
+
const total = panelQuotaLooksRemaining(kind)
|
|
118
|
+
? (quota === undefined || used === undefined ? quota : quota + used)
|
|
119
|
+
: quota;
|
|
120
|
+
const normalized = {
|
|
121
|
+
mode: "quota_limited",
|
|
122
|
+
quota: {
|
|
123
|
+
limit: total === undefined ? undefined : total / scale,
|
|
124
|
+
used: used === undefined ? undefined : used / scale,
|
|
125
|
+
remaining: remaining === undefined ? undefined : remaining / scale,
|
|
126
|
+
},
|
|
127
|
+
unit: "USD",
|
|
128
|
+
source: "panel-user-self",
|
|
129
|
+
raw: json,
|
|
130
|
+
};
|
|
131
|
+
return usageResult(
|
|
132
|
+
context,
|
|
133
|
+
"panel-user-self",
|
|
134
|
+
await formatPanelUserSelfLine(context.label, json, kind),
|
|
135
|
+
normalized
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Sub2API exposes user balance as USD at /api/v1/auth/me. Newer deployments may
|
|
140
|
+
// also expose richer subscription summaries through /v1/usage, so this route is
|
|
141
|
+
// a fallback for deployments where /v1/usage is unavailable.
|
|
142
|
+
async function fetchSub2ApiAuthMeUsage(context) {
|
|
143
|
+
const json = await requestJson(joinUrl(serviceRoot(context.baseUrl), "/api/v1/auth/me"), context.key, {
|
|
144
|
+
name: "Sub2API auth/me",
|
|
145
|
+
});
|
|
146
|
+
const root = usageRoot(json);
|
|
147
|
+
const balance = pickNumber(root, ["balance"]);
|
|
148
|
+
if (balance === undefined) throw new Error("Sub2API auth/me payload has no balance field");
|
|
149
|
+
const normalized = {
|
|
150
|
+
mode: "unrestricted",
|
|
151
|
+
planName: root?.username || root?.email || context.label || "Sub2API",
|
|
152
|
+
balance,
|
|
153
|
+
unit: "USD",
|
|
154
|
+
source: "sub2api-auth-me",
|
|
155
|
+
raw: json,
|
|
156
|
+
};
|
|
157
|
+
return usageResult(
|
|
158
|
+
context,
|
|
159
|
+
"sub2api-auth-me",
|
|
160
|
+
`API | balance ${formatMoney(balance)}`,
|
|
161
|
+
normalized
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// OpenRouter exposes normal API-key usage at /api/v1/key. Some accounts also
|
|
166
|
+
// expose credits at /api/v1/credits; keep this route isolated because
|
|
167
|
+
// OpenRouter's base URL already includes /api/v1, unlike NewAPI/OneAPI.
|
|
168
|
+
async function fetchOpenRouterUsage(context) {
|
|
169
|
+
const base = cleanBaseUrl(context.baseUrl).includes("/api/v1")
|
|
170
|
+
? cleanBaseUrl(context.baseUrl)
|
|
171
|
+
: joinUrl(serviceRoot(context.baseUrl), "/api/v1");
|
|
172
|
+
const endpoints = [
|
|
173
|
+
{ source: "openrouter-key", url: joinUrl(base, "/key") },
|
|
174
|
+
{ source: "openrouter-credits", url: joinUrl(base, "/credits") },
|
|
175
|
+
];
|
|
176
|
+
let lastError;
|
|
177
|
+
for (const endpoint of endpoints) {
|
|
178
|
+
try {
|
|
179
|
+
const json = await requestJson(endpoint.url, context.key, { name: endpoint.source });
|
|
180
|
+
return usageResult(context, endpoint.source, formatOpenRouterLine("OpenRouter", json), json);
|
|
181
|
+
} catch (error) {
|
|
182
|
+
lastError = error;
|
|
183
|
+
await debugLog({ source: endpoint.source, error: error.message });
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
throw lastError || new Error("OpenRouter usage unavailable");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const USAGE_ROUTES = {
|
|
190
|
+
"v1-usage": {
|
|
191
|
+
id: "v1-usage",
|
|
192
|
+
path: "/v1/usage",
|
|
193
|
+
run: fetchV1Usage,
|
|
194
|
+
},
|
|
195
|
+
"sub2api-auth-me": {
|
|
196
|
+
id: "sub2api-auth-me",
|
|
197
|
+
path: "/api/v1/auth/me",
|
|
198
|
+
run: fetchSub2ApiAuthMeUsage,
|
|
199
|
+
},
|
|
200
|
+
"newapi-token": {
|
|
201
|
+
id: "newapi-token",
|
|
202
|
+
path: "/api/usage/token/",
|
|
203
|
+
run: fetchNewApiTokenUsage,
|
|
204
|
+
},
|
|
205
|
+
"panel-user-self": {
|
|
206
|
+
id: "panel-user-self",
|
|
207
|
+
path: "/api/user/self",
|
|
208
|
+
run: fetchPanelUserSelfUsage,
|
|
209
|
+
},
|
|
210
|
+
"openrouter": {
|
|
211
|
+
id: "openrouter",
|
|
212
|
+
path: "/api/v1/key",
|
|
213
|
+
run: fetchOpenRouterUsage,
|
|
214
|
+
},
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
// Presets are probe-order aliases over the routes above, not separate
|
|
218
|
+
// protocols (e.g. anyrouter/agentrouter just try the NewAPI panel endpoints
|
|
219
|
+
// and /v1/usage in a different order).
|
|
220
|
+
// They do not provide panel session-cookie authentication; only endpoints that
|
|
221
|
+
// accept the configured Bearer key can succeed.
|
|
222
|
+
async function usageRouteIds(context) {
|
|
223
|
+
const preset = await usagePreset();
|
|
224
|
+
const routes = {
|
|
225
|
+
"sub2api": ["v1-usage", "sub2api-auth-me"],
|
|
226
|
+
"openai-compatible": ["v1-usage"],
|
|
227
|
+
"new-api": ["newapi-token", "panel-user-self"],
|
|
228
|
+
"one-api": ["newapi-token", "panel-user-self"],
|
|
229
|
+
"onehub": ["newapi-token", "panel-user-self"],
|
|
230
|
+
"one-hub": ["newapi-token", "panel-user-self"],
|
|
231
|
+
"donehub": ["newapi-token", "panel-user-self"],
|
|
232
|
+
"done-hub": ["newapi-token", "panel-user-self"],
|
|
233
|
+
"veloera": ["panel-user-self", "newapi-token"],
|
|
234
|
+
"anyrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
235
|
+
"agentrouter": ["newapi-token", "panel-user-self", "v1-usage"],
|
|
236
|
+
"openrouter": ["openrouter"],
|
|
237
|
+
};
|
|
238
|
+
if (routes[preset]) return routes[preset];
|
|
239
|
+
|
|
240
|
+
if (preset !== "auto") return [];
|
|
241
|
+
if (hostIncludes(context.baseUrl, "openrouter.ai")) return ["openrouter"];
|
|
242
|
+
return ["v1-usage", "sub2api-auth-me", "newapi-token", "panel-user-self"];
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function cachedUsageRoute(context) {
|
|
246
|
+
const cache = await readRouteCache();
|
|
247
|
+
const key = usageRouteCacheKey(context.baseUrl);
|
|
248
|
+
const route = cache.routes[key];
|
|
249
|
+
return route?.route && USAGE_ROUTES[route.route] ? route : null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export async function orderedUsageRoutes(context) {
|
|
253
|
+
const routeIds = await usageRouteIds(context);
|
|
254
|
+
const cached = await cachedUsageRoute(context);
|
|
255
|
+
if (!cached || !routeIds.includes(cached.route)) return routeIds.map((id) => USAGE_ROUTES[id]).filter(Boolean);
|
|
256
|
+
await debugLog({
|
|
257
|
+
source: "route-cache",
|
|
258
|
+
key: usageRouteCacheKey(context.baseUrl),
|
|
259
|
+
route: cached.route,
|
|
260
|
+
path: cached.path || USAGE_ROUTES[cached.route]?.path || "",
|
|
261
|
+
});
|
|
262
|
+
return [cached.route, ...routeIds.filter((id) => id !== cached.route)]
|
|
263
|
+
.map((id) => USAGE_ROUTES[id])
|
|
264
|
+
.filter(Boolean);
|
|
265
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Pure URL helpers for gateway base URLs and route cache keys.
|
|
2
|
+
|
|
3
|
+
export function isOfficialBaseUrl(baseUrl) {
|
|
4
|
+
if (!baseUrl) return true;
|
|
5
|
+
const clean = baseUrl.replace(/\/+$/, "");
|
|
6
|
+
return [
|
|
7
|
+
"https://api.openai.com",
|
|
8
|
+
"https://api.openai.com/v1",
|
|
9
|
+
"https://api.anthropic.com",
|
|
10
|
+
"https://api.anthropic.com/v1",
|
|
11
|
+
].includes(clean);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cleanBaseUrl(baseUrl) {
|
|
15
|
+
return String(baseUrl || "").replace(/\/+$/, "");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function serviceRoot(baseUrl) {
|
|
19
|
+
const clean = cleanBaseUrl(baseUrl);
|
|
20
|
+
return clean.endsWith("/v1") ? clean.slice(0, -3) : clean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function usageRouteCacheKey(baseUrl) {
|
|
24
|
+
try {
|
|
25
|
+
const url = new URL(cleanBaseUrl(baseUrl));
|
|
26
|
+
url.hash = "";
|
|
27
|
+
url.search = "";
|
|
28
|
+
url.pathname = url.pathname
|
|
29
|
+
.replace(/\/+$/, "")
|
|
30
|
+
.replace(/\/api\/v1$/i, "")
|
|
31
|
+
.replace(/\/v1$/i, "");
|
|
32
|
+
return url.toString().replace(/\/$/, "");
|
|
33
|
+
} catch {
|
|
34
|
+
return serviceRoot(baseUrl);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function joinUrl(baseUrl, path) {
|
|
39
|
+
return `${cleanBaseUrl(baseUrl)}${path.startsWith("/") ? path : `/${path}`}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function hostIncludes(baseUrl, value) {
|
|
43
|
+
try {
|
|
44
|
+
return new URL(baseUrl).hostname.toLowerCase().includes(value);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
package/package.json
CHANGED
package/scripts/install.mjs
CHANGED
|
@@ -102,40 +102,6 @@ function nodeCmd(absScript) {
|
|
|
102
102
|
return `node "${fwd(absScript)}"`;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
function stripJsonComments(input) {
|
|
106
|
-
let out = "";
|
|
107
|
-
let inString = false;
|
|
108
|
-
let escaped = false;
|
|
109
|
-
for (let i = 0; i < input.length; i++) {
|
|
110
|
-
const ch = input[i];
|
|
111
|
-
const next = input[i + 1];
|
|
112
|
-
if (inString) {
|
|
113
|
-
out += ch;
|
|
114
|
-
escaped = ch === "\\" ? !escaped : false;
|
|
115
|
-
if (ch === "\"" && !escaped) inString = false;
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
if (ch === "\"") {
|
|
119
|
-
inString = true;
|
|
120
|
-
out += ch;
|
|
121
|
-
continue;
|
|
122
|
-
}
|
|
123
|
-
if (ch === "/" && next === "/") {
|
|
124
|
-
while (i < input.length && input[i] !== "\n") i++;
|
|
125
|
-
out += "\n";
|
|
126
|
-
continue;
|
|
127
|
-
}
|
|
128
|
-
if (ch === "/" && next === "*") {
|
|
129
|
-
i += 2;
|
|
130
|
-
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
131
|
-
i++;
|
|
132
|
-
continue;
|
|
133
|
-
}
|
|
134
|
-
out += ch;
|
|
135
|
-
}
|
|
136
|
-
return out;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
105
|
function readJsonc(file) {
|
|
140
106
|
if (!fs.existsSync(file)) return {};
|
|
141
107
|
const raw = fs.readFileSync(file, "utf8").replace(/^\uFEFF/, "");
|
|
@@ -146,31 +112,6 @@ function readJsonc(file) {
|
|
|
146
112
|
return parsed ?? {};
|
|
147
113
|
}
|
|
148
114
|
|
|
149
|
-
function headerComments(text) {
|
|
150
|
-
const lines = text.replace(/^\uFEFF/, "").split(/\r?\n/);
|
|
151
|
-
const header = [];
|
|
152
|
-
for (const line of lines) {
|
|
153
|
-
if (/^\s*(?:\/\/.*)?$/.test(line)) {
|
|
154
|
-
header.push(line);
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
|
-
break;
|
|
158
|
-
}
|
|
159
|
-
return header.length ? header.join("\n").replace(/\s+$/, "") + "\n" : "";
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function mergeDefaults(target, defaults) {
|
|
163
|
-
if (Array.isArray(defaults)) return target === undefined ? defaults : target;
|
|
164
|
-
if (!defaults || typeof defaults !== "object") {
|
|
165
|
-
return target === undefined ? defaults : target;
|
|
166
|
-
}
|
|
167
|
-
const out = target && typeof target === "object" && !Array.isArray(target) ? { ...target } : {};
|
|
168
|
-
for (const [key, value] of Object.entries(defaults)) {
|
|
169
|
-
out[key] = mergeDefaults(out[key], value);
|
|
170
|
-
}
|
|
171
|
-
return out;
|
|
172
|
-
}
|
|
173
|
-
|
|
174
115
|
function writeText(file, text, dryRun) {
|
|
175
116
|
if (dryRun) {
|
|
176
117
|
console.log(` [dry-run] would write ${file}:`);
|
|
@@ -215,23 +156,46 @@ function updateOpenCodeTuiConfig(file, { remove, dryRun }) {
|
|
|
215
156
|
writeText(file, updated, dryRun);
|
|
216
157
|
}
|
|
217
158
|
|
|
159
|
+
// Keys present in defaults but absent in current, as jsonc-parser edit paths.
|
|
160
|
+
function missingDefaultPaths(current, defaults, basePath = []) {
|
|
161
|
+
const out = [];
|
|
162
|
+
for (const [key, value] of Object.entries(defaults)) {
|
|
163
|
+
const existing = current?.[key];
|
|
164
|
+
if (existing === undefined) {
|
|
165
|
+
out.push({ path: [...basePath, key], value });
|
|
166
|
+
} else if (
|
|
167
|
+
value && typeof value === "object" && !Array.isArray(value) &&
|
|
168
|
+
existing && typeof existing === "object" && !Array.isArray(existing)
|
|
169
|
+
) {
|
|
170
|
+
out.push(...missingDefaultPaths(existing, value, [...basePath, key]));
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Updates only add missing default keys via surgical jsonc-parser edits, so
|
|
177
|
+
// the user's comments, formatting, and key order survive installer updates.
|
|
218
178
|
function mergeJsoncFile(src, dest, dryRun) {
|
|
219
|
-
|
|
220
|
-
const defaultHeader = headerComments(fs.readFileSync(src, "utf8"));
|
|
221
|
-
if (!fs.existsSync(dest)) {
|
|
179
|
+
if (!fs.existsSync(dest) || !fs.readFileSync(dest, "utf8").trim()) {
|
|
222
180
|
writeText(dest, fs.readFileSync(src, "utf8"), dryRun);
|
|
223
181
|
return;
|
|
224
182
|
}
|
|
183
|
+
const defaults = readJsonc(src);
|
|
225
184
|
const currentText = fs.readFileSync(dest, "utf8");
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
const currentHeader = headerComments(currentText) || defaultHeader;
|
|
229
|
-
const mergedText = currentHeader + JSON.stringify(merged, null, 2) + "\n";
|
|
230
|
-
if (stripJsonComments(currentText).trim() === JSON.stringify(merged, null, 2)) {
|
|
185
|
+
const additions = missingDefaultPaths(readJsonc(dest), defaults);
|
|
186
|
+
if (additions.length === 0) {
|
|
231
187
|
console.log(` kept existing ${dest}`);
|
|
232
188
|
return;
|
|
233
189
|
}
|
|
234
|
-
|
|
190
|
+
const eol = currentText.includes("\r\n") ? "\r\n" : "\n";
|
|
191
|
+
let updated = currentText;
|
|
192
|
+
for (const { path: keyPath, value } of additions) {
|
|
193
|
+
const edits = modify(updated, keyPath, value, {
|
|
194
|
+
formattingOptions: { insertSpaces: true, tabSize: 2, eol },
|
|
195
|
+
});
|
|
196
|
+
updated = applyEdits(updated, edits);
|
|
197
|
+
}
|
|
198
|
+
writeText(dest, updated, dryRun);
|
|
235
199
|
}
|
|
236
200
|
|
|
237
201
|
function copyRuntimeFile(src, dest, dryRun, options = {}) {
|