@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 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`](./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`](./CHANGELOG.md)
193
+ - Release notes: [`CHANGELOG.md`](https://github.com/khanglvm/llm-router/blob/master/CHANGELOG.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@khanglvm/llm-router",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Single gateway endpoint for multi-provider LLMs with unified OpenAI+Anthropic format and seamless fallback",
5
5
  "keywords": [
6
6
  "llm-router",
@@ -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
+ }