@khanglvm/llm-router 1.0.8 → 1.1.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 CHANGED
@@ -5,6 +5,40 @@ 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.1.0] - 2026-03-04
9
+
10
+ ### Added
11
+ - Added full `config --operation=upsert-provider` UX support for subscription providers:
12
+ - `--type=subscription`
13
+ - `--subscription-type=chatgpt-codex`
14
+ - `--subscription-profile=<name>`
15
+ - Added subscription provider coverage tests for config workflows and runtime provider-call behavior.
16
+ - Added `.gitignore` rules for local IDE and deploy temp artifacts (`.idea/`, `.llm-router.deploy.*.wrangler.toml`).
17
+
18
+ ### Changed
19
+ - Updated config summaries and AI-help guidance to include subscription provider details and setup commands.
20
+ - Updated README setup guide with explicit ChatGPT Codex subscription onboarding flow.
21
+
22
+ ### Fixed
23
+ - Fixed subscription status command import path so `llm-router subscription status` works reliably.
24
+ - Fixed subscription provider request path to run standard request translation/mapping before OAuth-backed provider call.
25
+ - Fixed subscription provider config validation and normalization:
26
+ - subscription providers no longer require `baseUrl`
27
+ - predefined ChatGPT Codex model list is enforced during normalization.
28
+
29
+ ## [1.0.9] - 2026-03-03
30
+
31
+ ### Added
32
+ - Added dedicated modules for Cloudflare API preflight checks and Wrangler TOML target handling.
33
+ - Added runtime policy and route-debug helpers so stateful routing can be safely disabled by default on Cloudflare Worker.
34
+ - Added reusable timeout-signal utility and start-command port reclaim utilities with test coverage.
35
+
36
+ ### Changed
37
+ - Refactored CLI deploy/runtime handler code into focused modules with cleaner boundaries.
38
+ - Updated provider-call timeout handling to support both `AbortSignal.timeout` and `AbortController` fallback.
39
+ - Documented Worker safety defaults and switched README release/security links to canonical GitHub URLs.
40
+ - Added local start port resolution via `--port`, `LLM_ROUTER_PORT`, or generic `PORT` env variables.
41
+
8
42
  ## [1.0.8] - 2026-02-28
9
43
 
10
44
  ### Changed
package/README.md CHANGED
@@ -24,7 +24,7 @@ Run `llm-router ai-help` first, then set up and operate llm-router for me using
24
24
 
25
25
  ## Main Workflow
26
26
 
27
- 1. Add Providers + models into llm-router
27
+ 1. Add providers + models into llm-router (standard API-key providers or OAuth subscription providers)
28
28
  2. Optionally, group models as alias with load balancing and auto fallback support
29
29
  3. Start llm-router server, point your coding tool API and model to llm-router
30
30
 
@@ -77,10 +77,32 @@ Then follow this order.
77
77
  Flow:
78
78
  1. `Config manager`
79
79
  2. `Add/Edit provider`
80
- 3. Enter provider name, endpoint, API key
81
- 4. Enter model list
80
+ 3. Select provider type:
81
+ - `standard` -> endpoint + API key + model list
82
+ - `subscription` -> OAuth profile for predefined ChatGPT Codex models
83
+ 4. Enter provider details
82
84
  5. Save
83
85
 
86
+ ### 1b) Add Subscription Provider (ChatGPT Codex)
87
+ Commandline example:
88
+
89
+ ```bash
90
+ llm-router config \
91
+ --operation=upsert-provider \
92
+ --provider-id=chatgpt \
93
+ --name="ChatGPT Subscription" \
94
+ --type=subscription \
95
+ --subscription-type=chatgpt-codex \
96
+ --subscription-profile=default
97
+
98
+ llm-router subscription login --profile=default
99
+ llm-router subscription status --profile=default
100
+ ```
101
+
102
+ Notes:
103
+ - `chatgpt-codex` subscription providers use predefined model IDs managed by llm-router releases.
104
+ - No provider API key or endpoint probing is required for this provider type.
105
+
84
106
  ### 2) Configure Model Fallback (Optional)
85
107
  Flow:
86
108
  1. `Config manager`
@@ -127,10 +149,18 @@ Flow:
127
149
  llm-router start
128
150
  ```
129
151
 
152
+ Custom port (optional):
153
+
154
+ ```bash
155
+ llm-router start --port=3001
156
+ # or
157
+ LLM_ROUTER_PORT=3001 llm-router start
158
+ ```
159
+
130
160
  Local endpoints:
131
- - Unified: `http://127.0.0.1:8787/route`
132
- - Anthropic-style: `http://127.0.0.1:8787/anthropic`
133
- - OpenAI-style: `http://127.0.0.1:8787/openai`
161
+ - Unified: `http://127.0.0.1:<port>/route`
162
+ - Anthropic-style: `http://127.0.0.1:<port>/anthropic`
163
+ - OpenAI-style: `http://127.0.0.1:<port>/openai`
134
164
 
135
165
  ## Connect your coding tool
136
166
 
@@ -172,6 +202,11 @@ llm-router deploy
172
202
 
173
203
  You will be guided in TUI to select account and deploy target.
174
204
 
205
+ Worker safety defaults:
206
+ - `LLM_ROUTER_STATE_BACKEND=file` is ignored on Worker (auto-fallback to in-memory state).
207
+ - 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.
208
+ - To opt in to best-effort stateful behavior on Worker, set `LLM_ROUTER_WORKER_ALLOW_BEST_EFFORT_STATEFUL_ROUTING=true`.
209
+
175
210
  ## Config File Location
176
211
 
177
212
  Local config file:
@@ -180,9 +215,9 @@ Local config file:
180
215
 
181
216
  ## Security
182
217
 
183
- See [`SECURITY.md`](./SECURITY.md).
218
+ See [`SECURITY.md`](https://github.com/khanglvm/llm-router/blob/master/SECURITY.md).
184
219
 
185
220
  ## Versioning
186
221
 
187
222
  - Semver: [Semantic Versioning](https://semver.org/)
188
- - Release notes: [`CHANGELOG.md`](./CHANGELOG.md)
223
+ - 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.1.0",
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
+ }