@khanglvm/llm-router 1.0.8 → 1.0.9
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 +12 -0
- package/README.md +7 -2
- package/package.json +1 -1
- package/src/cli/cloudflare-api.js +267 -0
- package/src/cli/router-module.js +45 -568
- package/src/cli/wrangler-toml.js +324 -0
- package/src/index.js +3 -1
- package/src/node/port-reclaim.js +224 -0
- package/src/node/start-command.js +2 -128
- package/src/runtime/handler/provider-call.js +8 -2
- package/src/runtime/handler/route-debug.js +104 -0
- package/src/runtime/handler/runtime-policy.js +161 -0
- package/src/runtime/handler.js +43 -236
- package/src/shared/timeout-signal.js +23 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.0.9] - 2026-03-03
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
- Added dedicated modules for Cloudflare API preflight checks and Wrangler TOML target handling.
|
|
12
|
+
- Added runtime policy and route-debug helpers so stateful routing can be safely disabled by default on Cloudflare Worker.
|
|
13
|
+
- Added reusable timeout-signal utility and start-command port reclaim utilities with test coverage.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- Refactored CLI deploy/runtime handler code into focused modules with cleaner boundaries.
|
|
17
|
+
- Updated provider-call timeout handling to support both `AbortSignal.timeout` and `AbortController` fallback.
|
|
18
|
+
- Documented Worker safety defaults and switched README release/security links to canonical GitHub URLs.
|
|
19
|
+
|
|
8
20
|
## [1.0.8] - 2026-02-28
|
|
9
21
|
|
|
10
22
|
### Changed
|
package/README.md
CHANGED
|
@@ -172,6 +172,11 @@ llm-router deploy
|
|
|
172
172
|
|
|
173
173
|
You will be guided in TUI to select account and deploy target.
|
|
174
174
|
|
|
175
|
+
Worker safety defaults:
|
|
176
|
+
- `LLM_ROUTER_STATE_BACKEND=file` is ignored on Worker (auto-fallback to in-memory state).
|
|
177
|
+
- Stateful timing-dependent routing features (cursor balancing, local quota counters, cooldown persistence) are auto-disabled by default to keep route flow safe across Worker isolates.
|
|
178
|
+
- To opt in to best-effort stateful behavior on Worker, set `LLM_ROUTER_WORKER_ALLOW_BEST_EFFORT_STATEFUL_ROUTING=true`.
|
|
179
|
+
|
|
175
180
|
## Config File Location
|
|
176
181
|
|
|
177
182
|
Local config file:
|
|
@@ -180,9 +185,9 @@ Local config file:
|
|
|
180
185
|
|
|
181
186
|
## Security
|
|
182
187
|
|
|
183
|
-
See [`SECURITY.md`](
|
|
188
|
+
See [`SECURITY.md`](https://github.com/khanglvm/llm-router/blob/master/SECURITY.md).
|
|
184
189
|
|
|
185
190
|
## Versioning
|
|
186
191
|
|
|
187
192
|
- Semver: [Semantic Versioning](https://semver.org/)
|
|
188
|
-
- Release notes: [`CHANGELOG.md`](
|
|
193
|
+
- Release notes: [`CHANGELOG.md`](https://github.com/khanglvm/llm-router/blob/master/CHANGELOG.md)
|
package/package.json
CHANGED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
import { buildTimeoutSignal } from "../shared/timeout-signal.js";
|
|
2
|
+
|
|
3
|
+
const CLOUDFLARE_API_BASE_URL = "https://api.cloudflare.com/client/v4";
|
|
4
|
+
const CLOUDFLARE_VERIFY_TOKEN_URL = `${CLOUDFLARE_API_BASE_URL}/user/tokens/verify`;
|
|
5
|
+
const CLOUDFLARE_MEMBERSHIPS_URL = `${CLOUDFLARE_API_BASE_URL}/memberships`;
|
|
6
|
+
const CLOUDFLARE_ZONES_URL = `${CLOUDFLARE_API_BASE_URL}/zones`;
|
|
7
|
+
const CLOUDFLARE_API_PREFLIGHT_TIMEOUT_MS = 10_000;
|
|
8
|
+
|
|
9
|
+
export const CLOUDFLARE_API_TOKEN_ENV_NAME = "CLOUDFLARE_API_TOKEN";
|
|
10
|
+
export const CLOUDFLARE_API_TOKEN_ALT_ENV_NAME = "CF_API_TOKEN";
|
|
11
|
+
export const CLOUDFLARE_ACCOUNT_ID_ENV_NAME = "CLOUDFLARE_ACCOUNT_ID";
|
|
12
|
+
const CLOUDFLARE_API_TOKEN_PRESET_NAME = "Edit Cloudflare Workers";
|
|
13
|
+
const CLOUDFLARE_API_TOKEN_DASHBOARD_URL = "https://dash.cloudflare.com/profile/api-tokens";
|
|
14
|
+
const CLOUDFLARE_API_TOKEN_GUIDE_URL = "https://developers.cloudflare.com/fundamentals/api/get-started/create-token/";
|
|
15
|
+
|
|
16
|
+
function parseJsonSafely(value) {
|
|
17
|
+
const text = String(value || "").trim();
|
|
18
|
+
if (!text) return null;
|
|
19
|
+
try {
|
|
20
|
+
return JSON.parse(text);
|
|
21
|
+
} catch {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function resolveCloudflareApiTokenFromEnv(env = process.env) {
|
|
27
|
+
const primary = String(env?.[CLOUDFLARE_API_TOKEN_ENV_NAME] || "").trim();
|
|
28
|
+
if (primary) {
|
|
29
|
+
return {
|
|
30
|
+
token: primary,
|
|
31
|
+
source: CLOUDFLARE_API_TOKEN_ENV_NAME
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const fallback = String(env?.[CLOUDFLARE_API_TOKEN_ALT_ENV_NAME] || "").trim();
|
|
36
|
+
if (fallback) {
|
|
37
|
+
return {
|
|
38
|
+
token: fallback,
|
|
39
|
+
source: CLOUDFLARE_API_TOKEN_ALT_ENV_NAME
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return {
|
|
44
|
+
token: "",
|
|
45
|
+
source: "none"
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function buildCloudflareApiTokenSetupGuide() {
|
|
50
|
+
return [
|
|
51
|
+
`Cloudflare deploy requires ${CLOUDFLARE_API_TOKEN_ENV_NAME}.`,
|
|
52
|
+
`Create a User Profile API token in dashboard: ${CLOUDFLARE_API_TOKEN_DASHBOARD_URL}`,
|
|
53
|
+
"Do not use Account API Tokens for this deploy flow.",
|
|
54
|
+
`Token docs: ${CLOUDFLARE_API_TOKEN_GUIDE_URL}`,
|
|
55
|
+
`Recommended preset: ${CLOUDFLARE_API_TOKEN_PRESET_NAME}.`,
|
|
56
|
+
`Then set ${CLOUDFLARE_API_TOKEN_ENV_NAME} in your shell/CI environment.`
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function validateCloudflareApiTokenInput(value) {
|
|
61
|
+
const candidate = String(value || "").trim();
|
|
62
|
+
if (!candidate) return `${CLOUDFLARE_API_TOKEN_ENV_NAME} is required for deploy.`;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function buildCloudflareApiTokenTroubleshooting(preflightMessage = "") {
|
|
67
|
+
return [
|
|
68
|
+
preflightMessage,
|
|
69
|
+
"Required token capabilities for wrangler deploy:",
|
|
70
|
+
"- User details: Read",
|
|
71
|
+
"- User memberships: Read",
|
|
72
|
+
`- Account preset/template: ${CLOUDFLARE_API_TOKEN_PRESET_NAME}`,
|
|
73
|
+
`Verify token manually: curl "${CLOUDFLARE_VERIFY_TOKEN_URL}" -H "Authorization: Bearer $${CLOUDFLARE_API_TOKEN_ENV_NAME}"`,
|
|
74
|
+
buildCloudflareApiTokenSetupGuide()
|
|
75
|
+
].filter(Boolean).join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function normalizeCloudflareMembershipAccount(entry) {
|
|
79
|
+
if (!entry || typeof entry !== "object") return null;
|
|
80
|
+
const accountObj = entry.account && typeof entry.account === "object" ? entry.account : {};
|
|
81
|
+
const accountId = String(
|
|
82
|
+
accountObj.id
|
|
83
|
+
|| entry.account_id
|
|
84
|
+
|| entry.accountId
|
|
85
|
+
|| entry.id
|
|
86
|
+
|| ""
|
|
87
|
+
).trim();
|
|
88
|
+
if (!accountId) return null;
|
|
89
|
+
|
|
90
|
+
const accountName = String(
|
|
91
|
+
accountObj.name
|
|
92
|
+
|| entry.account_name
|
|
93
|
+
|| entry.accountName
|
|
94
|
+
|| entry.name
|
|
95
|
+
|| `Account ${accountId.slice(0, 8)}`
|
|
96
|
+
).trim();
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
accountId,
|
|
100
|
+
accountName: accountName || `Account ${accountId.slice(0, 8)}`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function extractCloudflareMembershipAccounts(payload) {
|
|
105
|
+
const list = Array.isArray(payload?.result) ? payload.result : [];
|
|
106
|
+
const map = new Map();
|
|
107
|
+
for (const entry of list) {
|
|
108
|
+
const normalized = normalizeCloudflareMembershipAccount(entry);
|
|
109
|
+
if (!normalized) continue;
|
|
110
|
+
if (!map.has(normalized.accountId)) {
|
|
111
|
+
map.set(normalized.accountId, normalized);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return Array.from(map.values());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function cloudflareErrorFromPayload(payload, fallback) {
|
|
118
|
+
const base = String(fallback || "Unknown Cloudflare API error");
|
|
119
|
+
if (!payload || typeof payload !== "object") return base;
|
|
120
|
+
|
|
121
|
+
const errors = Array.isArray(payload.errors) ? payload.errors : [];
|
|
122
|
+
const first = errors.find((entry) => entry && typeof entry === "object");
|
|
123
|
+
if (!first) return base;
|
|
124
|
+
|
|
125
|
+
const code = Number.isFinite(first.code) ? `code ${first.code}` : "";
|
|
126
|
+
const message = String(first.message || first.error || "").trim();
|
|
127
|
+
if (code && message) return `${message} (${code})`;
|
|
128
|
+
if (message) return message;
|
|
129
|
+
if (code) return code;
|
|
130
|
+
return base;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function evaluateCloudflareTokenVerifyResult(payload) {
|
|
134
|
+
const status = String(payload?.result?.status || "").toLowerCase();
|
|
135
|
+
const active = payload?.success === true && status === "active";
|
|
136
|
+
if (active) {
|
|
137
|
+
return { ok: true, message: "Token is active." };
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
message: cloudflareErrorFromPayload(
|
|
142
|
+
payload,
|
|
143
|
+
"Token verification failed. Ensure token is valid and active."
|
|
144
|
+
)
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function evaluateCloudflareMembershipsResult(payload) {
|
|
149
|
+
if (payload?.success !== true || !Array.isArray(payload?.result)) {
|
|
150
|
+
return {
|
|
151
|
+
ok: false,
|
|
152
|
+
message: cloudflareErrorFromPayload(
|
|
153
|
+
payload,
|
|
154
|
+
"Could not list Cloudflare memberships for this token."
|
|
155
|
+
)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (payload.result.length === 0) {
|
|
160
|
+
return {
|
|
161
|
+
ok: false,
|
|
162
|
+
message: "Token can authenticate but has no accessible memberships."
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const accounts = extractCloudflareMembershipAccounts(payload);
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
message: `Token has access to ${payload.result.length} membership(s).`,
|
|
170
|
+
count: payload.result.length,
|
|
171
|
+
accounts
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function cloudflareApiGetJson(url, token) {
|
|
176
|
+
const timeoutControl = buildTimeoutSignal(CLOUDFLARE_API_PREFLIGHT_TIMEOUT_MS);
|
|
177
|
+
try {
|
|
178
|
+
const response = await fetch(url, {
|
|
179
|
+
method: "GET",
|
|
180
|
+
headers: {
|
|
181
|
+
Authorization: `Bearer ${token}`
|
|
182
|
+
},
|
|
183
|
+
signal: timeoutControl.signal
|
|
184
|
+
});
|
|
185
|
+
const rawText = await response.text();
|
|
186
|
+
const payload = parseJsonSafely(rawText) || {};
|
|
187
|
+
return {
|
|
188
|
+
ok: response.ok,
|
|
189
|
+
status: response.status,
|
|
190
|
+
payload
|
|
191
|
+
};
|
|
192
|
+
} catch (error) {
|
|
193
|
+
return {
|
|
194
|
+
ok: false,
|
|
195
|
+
status: 0,
|
|
196
|
+
payload: null,
|
|
197
|
+
error: error instanceof Error ? error.message : String(error)
|
|
198
|
+
};
|
|
199
|
+
} finally {
|
|
200
|
+
timeoutControl.cleanup();
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export async function preflightCloudflareApiToken(token) {
|
|
205
|
+
const verified = await cloudflareApiGetJson(CLOUDFLARE_VERIFY_TOKEN_URL, token);
|
|
206
|
+
if (verified.status === 0) {
|
|
207
|
+
return {
|
|
208
|
+
ok: false,
|
|
209
|
+
stage: "verify",
|
|
210
|
+
message: `Cloudflare token preflight failed while verifying token: ${verified.error || "network error"}`
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const verifyEval = evaluateCloudflareTokenVerifyResult(verified.payload);
|
|
215
|
+
if (!verified.ok || !verifyEval.ok) {
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
stage: "verify",
|
|
219
|
+
message: `Cloudflare token verification failed: ${verifyEval.message}`
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const memberships = await cloudflareApiGetJson(CLOUDFLARE_MEMBERSHIPS_URL, token);
|
|
224
|
+
if (memberships.status === 0) {
|
|
225
|
+
return {
|
|
226
|
+
ok: false,
|
|
227
|
+
stage: "memberships",
|
|
228
|
+
message: `Cloudflare token preflight failed while checking memberships: ${memberships.error || "network error"}`
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const membershipEval = evaluateCloudflareMembershipsResult(memberships.payload);
|
|
233
|
+
if (!memberships.ok || !membershipEval.ok) {
|
|
234
|
+
return {
|
|
235
|
+
ok: false,
|
|
236
|
+
stage: "memberships",
|
|
237
|
+
message: `Cloudflare memberships check failed: ${membershipEval.message}`
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
return {
|
|
242
|
+
ok: true,
|
|
243
|
+
stage: "ready",
|
|
244
|
+
message: membershipEval.message,
|
|
245
|
+
memberships: membershipEval.accounts || []
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function normalizeHostname(value) {
|
|
250
|
+
return String(value || "")
|
|
251
|
+
.trim()
|
|
252
|
+
.toLowerCase()
|
|
253
|
+
.replace(/^https?:\/\//, "")
|
|
254
|
+
.replace(/\/.*$/, "")
|
|
255
|
+
.replace(/:\d+$/, "")
|
|
256
|
+
.replace(/\.$/, "");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export async function cloudflareListZones(token, accountId = "") {
|
|
260
|
+
const params = new URLSearchParams({ per_page: "50" });
|
|
261
|
+
if (accountId) params.set("account.id", accountId);
|
|
262
|
+
const result = await cloudflareApiGetJson(`${CLOUDFLARE_ZONES_URL}?${params.toString()}`, token);
|
|
263
|
+
if (!result.ok || !Array.isArray(result.payload?.result)) return [];
|
|
264
|
+
return result.payload.result
|
|
265
|
+
.map((zone) => ({ id: String(zone?.id || "").trim(), name: normalizeHostname(zone?.name || "") }))
|
|
266
|
+
.filter((zone) => zone.id && zone.name);
|
|
267
|
+
}
|