jishushell 0.0.1 → 0.4.2-beta2

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 (139) hide show
  1. package/INSTALL-NOTICE +41 -0
  2. package/LICENSE +202 -0
  3. package/README.md +36 -0
  4. package/THIRD-PARTY-NOTICES +387 -0
  5. package/dist/auth.d.ts +6 -0
  6. package/dist/auth.js +88 -0
  7. package/dist/auth.js.map +1 -0
  8. package/dist/cli.d.ts +2 -0
  9. package/dist/cli.js +290 -0
  10. package/dist/cli.js.map +1 -0
  11. package/dist/config.d.ts +24 -0
  12. package/dist/config.js +226 -0
  13. package/dist/config.js.map +1 -0
  14. package/dist/constants.d.ts +3 -0
  15. package/dist/constants.js +15 -0
  16. package/dist/constants.js.map +1 -0
  17. package/dist/control.d.ts +44 -0
  18. package/dist/control.js +1359 -0
  19. package/dist/control.js.map +1 -0
  20. package/dist/crypto-shim.d.ts +1 -0
  21. package/dist/crypto-shim.js +2 -0
  22. package/dist/crypto-shim.js.map +1 -0
  23. package/dist/doctor.d.ts +46 -0
  24. package/dist/doctor.js +937 -0
  25. package/dist/doctor.js.map +1 -0
  26. package/dist/install.d.ts +27 -0
  27. package/dist/install.js +570 -0
  28. package/dist/install.js.map +1 -0
  29. package/dist/routes/auth.d.ts +4 -0
  30. package/dist/routes/auth.js +151 -0
  31. package/dist/routes/auth.js.map +1 -0
  32. package/dist/routes/instances.d.ts +2 -0
  33. package/dist/routes/instances.js +1303 -0
  34. package/dist/routes/instances.js.map +1 -0
  35. package/dist/routes/setup.d.ts +2 -0
  36. package/dist/routes/setup.js +139 -0
  37. package/dist/routes/setup.js.map +1 -0
  38. package/dist/routes/system.d.ts +2 -0
  39. package/dist/routes/system.js +102 -0
  40. package/dist/routes/system.js.map +1 -0
  41. package/dist/server.d.ts +6 -0
  42. package/dist/server.js +392 -0
  43. package/dist/server.js.map +1 -0
  44. package/dist/services/instance-manager.d.ts +67 -0
  45. package/dist/services/instance-manager.js +1319 -0
  46. package/dist/services/instance-manager.js.map +1 -0
  47. package/dist/services/llm-proxy/adapters.d.ts +3 -0
  48. package/dist/services/llm-proxy/adapters.js +309 -0
  49. package/dist/services/llm-proxy/adapters.js.map +1 -0
  50. package/dist/services/llm-proxy/circuit-breaker.d.ts +9 -0
  51. package/dist/services/llm-proxy/circuit-breaker.js +73 -0
  52. package/dist/services/llm-proxy/circuit-breaker.js.map +1 -0
  53. package/dist/services/llm-proxy/encryption.d.ts +6 -0
  54. package/dist/services/llm-proxy/encryption.js +61 -0
  55. package/dist/services/llm-proxy/encryption.js.map +1 -0
  56. package/dist/services/llm-proxy/index.d.ts +24 -0
  57. package/dist/services/llm-proxy/index.js +708 -0
  58. package/dist/services/llm-proxy/index.js.map +1 -0
  59. package/dist/services/llm-proxy/rate-limiter.d.ts +1 -0
  60. package/dist/services/llm-proxy/rate-limiter.js +39 -0
  61. package/dist/services/llm-proxy/rate-limiter.js.map +1 -0
  62. package/dist/services/llm-proxy/sse.d.ts +10 -0
  63. package/dist/services/llm-proxy/sse.js +378 -0
  64. package/dist/services/llm-proxy/sse.js.map +1 -0
  65. package/dist/services/llm-proxy/ssrf.d.ts +16 -0
  66. package/dist/services/llm-proxy/ssrf.js +185 -0
  67. package/dist/services/llm-proxy/ssrf.js.map +1 -0
  68. package/dist/services/llm-proxy/types.d.ts +52 -0
  69. package/dist/services/llm-proxy/types.js +2 -0
  70. package/dist/services/llm-proxy/types.js.map +1 -0
  71. package/dist/services/llm-proxy/usage.d.ts +12 -0
  72. package/dist/services/llm-proxy/usage.js +108 -0
  73. package/dist/services/llm-proxy/usage.js.map +1 -0
  74. package/dist/services/nomad-manager.d.ts +22 -0
  75. package/dist/services/nomad-manager.js +828 -0
  76. package/dist/services/nomad-manager.js.map +1 -0
  77. package/dist/services/plugin-installer.d.ts +22 -0
  78. package/dist/services/plugin-installer.js +102 -0
  79. package/dist/services/plugin-installer.js.map +1 -0
  80. package/dist/services/process-manager.d.ts +25 -0
  81. package/dist/services/process-manager.js +531 -0
  82. package/dist/services/process-manager.js.map +1 -0
  83. package/dist/services/setup-manager.d.ts +93 -0
  84. package/dist/services/setup-manager.js +1922 -0
  85. package/dist/services/setup-manager.js.map +1 -0
  86. package/dist/services/system-monitor.d.ts +1 -0
  87. package/dist/services/system-monitor.js +79 -0
  88. package/dist/services/system-monitor.js.map +1 -0
  89. package/dist/services/telemetry/activation.d.ts +12 -0
  90. package/dist/services/telemetry/activation.js +78 -0
  91. package/dist/services/telemetry/activation.js.map +1 -0
  92. package/dist/services/telemetry/client.d.ts +21 -0
  93. package/dist/services/telemetry/client.js +36 -0
  94. package/dist/services/telemetry/client.js.map +1 -0
  95. package/dist/services/telemetry/device-fingerprint.d.ts +18 -0
  96. package/dist/services/telemetry/device-fingerprint.js +123 -0
  97. package/dist/services/telemetry/device-fingerprint.js.map +1 -0
  98. package/dist/services/telemetry/heartbeat.d.ts +13 -0
  99. package/dist/services/telemetry/heartbeat.js +87 -0
  100. package/dist/services/telemetry/heartbeat.js.map +1 -0
  101. package/dist/services/telemetry/index.d.ts +3 -0
  102. package/dist/services/telemetry/index.js +4 -0
  103. package/dist/services/telemetry/index.js.map +1 -0
  104. package/dist/types.d.ts +51 -0
  105. package/dist/types.js +2 -0
  106. package/dist/types.js.map +1 -0
  107. package/dist/utils/safe-json.d.ts +2 -0
  108. package/dist/utils/safe-json.js +80 -0
  109. package/dist/utils/safe-json.js.map +1 -0
  110. package/dist/utils/ttl-cache.d.ts +29 -0
  111. package/dist/utils/ttl-cache.js +77 -0
  112. package/dist/utils/ttl-cache.js.map +1 -0
  113. package/install/jishu-install.sh +2920 -0
  114. package/install/jishu-uninstall.sh +811 -0
  115. package/install/post-install.sh +124 -0
  116. package/install/post-uninstall.sh +46 -0
  117. package/package.json +57 -8
  118. package/public/assets/Dashboard-Dxsq690N.js +1 -0
  119. package/public/assets/InitPassword-CslWYy8G.js +1 -0
  120. package/public/assets/InstanceDetail-DmEkMj-t.js +14 -0
  121. package/public/assets/Login-d45wtgVA.js +1 -0
  122. package/public/assets/NewInstance-Czp5-AJe.js +1 -0
  123. package/public/assets/Settings-BKMGck05.js +1 -0
  124. package/public/assets/Setup-D3rfLWjZ.js +1 -0
  125. package/public/assets/index-77Ug7feY.css +1 -0
  126. package/public/assets/index-DkDnIohs.js +16 -0
  127. package/public/assets/logo-black-theme-DywLAtFy.png +0 -0
  128. package/public/assets/logo-white-theme-DXffFAWw.png +0 -0
  129. package/public/assets/providers-lBSOjUWy.js +1 -0
  130. package/public/assets/usePolling-CqQ8hrNc.js +1 -0
  131. package/public/assets/vendor-i18n-Bvxxh8Di.js +9 -0
  132. package/public/assets/vendor-react-DONn7uBV.js +59 -0
  133. package/public/index.html +15 -0
  134. package/scripts/build-image.sh +55 -0
  135. package/scripts/run.sh +310 -0
  136. package/scripts/setup-pi.sh +80 -0
  137. package/scripts/start-feishu1.js +46 -0
  138. package/index.js +0 -0
  139. package/jishushell-0.0.1.tgz +0 -0
@@ -0,0 +1,708 @@
1
+ /**
2
+ * Embedded LLM Proxy.
3
+ * Routes OpenAI-compatible requests to upstream providers with protocol adaptation.
4
+ */
5
+ import { randomBytes, timingSafeEqual } from "crypto";
6
+ import { dirname, join } from "path";
7
+ import * as instanceManager from "../instance-manager.js";
8
+ import { getNomadDriver, getPanelConfig, getServiceManagerType, } from "../../config.js";
9
+ import { PROXY_PROVIDER_ID, LEGACY_PROVIDER_API_ALIASES as LEGACY_API_ALIASES } from "../../constants.js";
10
+ import { TtlMap } from "../../utils/ttl-cache.js";
11
+ // ── Sub-module re-exports ──
12
+ export { encryptApiKey, decryptApiKey, decryptApiKeyWithInfo } from "./encryption.js";
13
+ export { flushUsage, getProxyUsage } from "./usage.js";
14
+ import { decryptApiKey, decryptApiKeyWithInfo, encryptApiKey } from "./encryption.js";
15
+ import { checkRateLimit } from "./rate-limiter.js";
16
+ import { checkCircuit, recordCircuitSuccess, recordCircuitFailure, resetCircuit } from "./circuit-breaker.js";
17
+ import { validateUpstreamUrl, pinnedFetch, LOCAL_PROVIDER_IDS } from "./ssrf.js";
18
+ import { trackUsage } from "./usage.js";
19
+ import { buildUpstreamRequest, buildResponsesPassthrough } from "./adapters.js";
20
+ import { pipeSSEDirect, pipeAnthropicToOpenAI, convertAnthropicJsonResponse, readUpstreamError, sanitizeErrorMessage } from "./sse.js";
21
+ // ── Constants ──
22
+ const PROXY_TOKEN_PREFIX = "sk-js-";
23
+ const PROXY_TOKEN_ENV_NAME = "JSPROXY_API_KEY";
24
+ const UPSTREAM_SECRET_ENV_NAME = "UPSTREAM_API_KEY";
25
+ const PROXY_BASE_PATH = "/proxy";
26
+ let _proxyPort = 8090;
27
+ export function setProxyPort(port) { _proxyPort = port; }
28
+ // Timeouts & limits
29
+ const UPSTREAM_TIMEOUT_MS = 120_000;
30
+ const UPSTREAM_STREAM_TIMEOUT_MS = 300_000;
31
+ const MAX_RETRIES = 2;
32
+ const RETRY_BASE_DELAY_MS = 500;
33
+ // ── In-memory state ──
34
+ const tokenMap = new Map(); // proxyToken → instanceId
35
+ const configCache = new TtlMap(30_000);
36
+ // ── Config Cache ──
37
+ function getCachedUpstream(instanceId) {
38
+ return configCache.get(instanceId);
39
+ }
40
+ function setCachedUpstream(instanceId, upstream, apiKey) {
41
+ configCache.set(instanceId, { upstream, apiKey, timestamp: Date.now() });
42
+ }
43
+ export function invalidateConfigCache(instanceId) {
44
+ configCache.delete(instanceId);
45
+ }
46
+ // ── Retry Wrapper ──
47
+ async function fetchWithRetry(url, options, maxRetries, resolved) {
48
+ let lastError = null;
49
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
50
+ try {
51
+ const resp = resolved
52
+ ? await pinnedFetch(url, options, resolved)
53
+ : await fetch(url, options);
54
+ if (resp.ok || attempt >= maxRetries)
55
+ return resp;
56
+ if (resp.status >= 500 || resp.status === 429) {
57
+ try {
58
+ resp.body?.cancel();
59
+ }
60
+ catch { /* ignore */ }
61
+ const retryAfterStr = resp.headers.get("retry-after");
62
+ const retryAfterSec = retryAfterStr ? parseInt(retryAfterStr, 10) : NaN;
63
+ const delay = Number.isFinite(retryAfterSec)
64
+ ? Math.min(retryAfterSec * 1000, 10_000)
65
+ : RETRY_BASE_DELAY_MS * Math.pow(2, attempt);
66
+ await new Promise((r) => setTimeout(r, delay));
67
+ continue;
68
+ }
69
+ return resp; // 4xx (except 429) — don't retry
70
+ }
71
+ catch (err) {
72
+ lastError = err;
73
+ if (err.name === "AbortError")
74
+ throw err;
75
+ if (attempt < maxRetries) {
76
+ await new Promise((r) => setTimeout(r, RETRY_BASE_DELAY_MS * Math.pow(2, attempt)));
77
+ continue;
78
+ }
79
+ }
80
+ }
81
+ throw lastError || new Error("fetch failed after retries");
82
+ }
83
+ // ── Helpers ──
84
+ function normalizeModel(model) {
85
+ if (typeof model !== "object" || model === null)
86
+ return null;
87
+ const id = String(model.id || "").trim();
88
+ if (!id)
89
+ return null;
90
+ let contextWindow = 128000;
91
+ try {
92
+ contextWindow = Number(model.contextWindow) || 128000;
93
+ }
94
+ catch { /* ignore */ }
95
+ // Preserve all upstream model properties (input, maxTokens, reasoning, cost, etc.)
96
+ const { id: _id, name: _name, contextWindow: _cw, ...rest } = model;
97
+ return { id, name: String(model.name || id), contextWindow, ...rest };
98
+ }
99
+ function instanceProviderSecretFile(instanceId) {
100
+ const envFile = instanceManager.getRuntimeEnvFiles(instanceId)[0];
101
+ return join(dirname(envFile), "provider.env");
102
+ }
103
+ function readUpstreamApiKey(instanceId) {
104
+ const secretFile = instanceProviderSecretFile(instanceId);
105
+ const raw = instanceManager.parseEnvFile(secretFile)[UPSTREAM_SECRET_ENV_NAME] || "";
106
+ if (raw) {
107
+ const { value, usedLegacy } = decryptApiKeyWithInfo(raw);
108
+ if (value && usedLegacy) {
109
+ // Auto-migrate: re-encrypt with current AES key
110
+ try {
111
+ instanceManager.updateEnvFile(secretFile, { [UPSTREAM_SECRET_ENV_NAME]: encryptApiKey(value) });
112
+ console.log(`[llm-proxy] auto-migrated API key for instance ${instanceId} to current encryption key`);
113
+ }
114
+ catch { /* ignore migration errors — key still works */ }
115
+ }
116
+ if (value)
117
+ return value;
118
+ }
119
+ const dp = getPanelConfig().default_provider;
120
+ return (dp?.apiKey && !dp?.skipped) ? decryptApiKey(dp.apiKey) : "";
121
+ }
122
+ function readLegacyProviderApiKey(instanceId, providerId) {
123
+ if (!providerId)
124
+ return "";
125
+ const envName = instanceManager.inferProviderApiKeyEnvName(providerId);
126
+ const raw = instanceManager.getRuntimeEnv(instanceId)[envName] || "";
127
+ return decryptApiKey(raw);
128
+ }
129
+ function readProxyToken(instanceId) {
130
+ const envFile = instanceManager.getRuntimeEnvFiles(instanceId)[0];
131
+ return instanceManager.parseEnvFile(envFile)[PROXY_TOKEN_ENV_NAME] || "";
132
+ }
133
+ function proxyBaseUrl() {
134
+ const host = getServiceManagerType() === "nomad" && getNomadDriver() === "docker"
135
+ ? "host.docker.internal"
136
+ : "127.0.0.1";
137
+ return `http://${host}:${_proxyPort}${PROXY_BASE_PATH}/v1`;
138
+ }
139
+ /**
140
+ * Bootstrap proxy for a newly created instance: generate proxy token,
141
+ * write model.env, and patch the config with jsproxy/default so the
142
+ * instance is ready to run without a manual "save config" step.
143
+ */
144
+ export async function bootstrapInstanceProxy(instanceId) {
145
+ const config = instanceManager.getConfig(instanceId);
146
+ if (!config)
147
+ return;
148
+ const upstream = deriveUpstreamConfig(instanceId, config);
149
+ if (!upstream)
150
+ return; // no provider configured, skip bootstrap
151
+ const proxyToken = ensureInstanceProxyToken(instanceId);
152
+ applyProxyConfig(config, upstream, proxyToken);
153
+ attachProxyMetadata(config, instanceId, upstream);
154
+ instanceManager.saveConfig(instanceId, config);
155
+ // Register the new token in the token map
156
+ tokenMap.set(proxyToken, instanceId);
157
+ }
158
+ // ── Token Management ──
159
+ let _configChangeCleanup = null;
160
+ export function rebuildTokenMap() {
161
+ // Unsubscribe previous listener to prevent leak on repeated calls
162
+ if (_configChangeCleanup)
163
+ _configChangeCleanup();
164
+ _configChangeCleanup = instanceManager.onConfigChange((id) => configCache.delete(id));
165
+ tokenMap.clear();
166
+ for (const inst of instanceManager.listInstances()) {
167
+ const envFile = instanceManager.getRuntimeEnvFiles(inst.id)[0];
168
+ const envVars = instanceManager.parseEnvFile(envFile);
169
+ const token = envVars[PROXY_TOKEN_ENV_NAME];
170
+ if (token)
171
+ tokenMap.set(token, inst.id);
172
+ }
173
+ // Migrate legacy JWT-derived encryption keys on startup
174
+ migrateAllLegacyKeys();
175
+ }
176
+ /**
177
+ * Migrate all instance API keys from legacy JWT-derived encryption to the
178
+ * dedicated AES key. This eliminates the security risk of JWT secret
179
+ * compromise exposing all stored API keys.
180
+ */
181
+ function migrateAllLegacyKeys() {
182
+ let migrated = 0;
183
+ let remaining = 0;
184
+ for (const inst of instanceManager.listInstances()) {
185
+ try {
186
+ const secretFile = instanceProviderSecretFile(inst.id);
187
+ const raw = instanceManager.parseEnvFile(secretFile)[UPSTREAM_SECRET_ENV_NAME] || "";
188
+ if (!raw)
189
+ continue;
190
+ const { value, usedLegacy } = decryptApiKeyWithInfo(raw);
191
+ if (value && usedLegacy) {
192
+ instanceManager.updateEnvFile(secretFile, { [UPSTREAM_SECRET_ENV_NAME]: encryptApiKey(value) });
193
+ migrated++;
194
+ }
195
+ }
196
+ catch (e) {
197
+ console.warn(`[llm-proxy] failed to migrate key for instance ${inst.id}:`, e.message);
198
+ remaining++;
199
+ }
200
+ }
201
+ if (migrated > 0) {
202
+ console.log(`[llm-proxy] migrated ${migrated} API key(s) from legacy encryption to current AES key`);
203
+ }
204
+ if (remaining > 0) {
205
+ // Some keys could not be migrated — legacy fallback must stay active.
206
+ // Retry by calling POST /api/admin/migrate-secrets after fixing the affected instances.
207
+ console.warn(`[llm-proxy] WARNING: ${remaining} instance(s) could not be migrated — legacy JWT-derived key fallback remains active`);
208
+ }
209
+ else if (migrated === 0) {
210
+ // All keys already use the current AES key; legacy fallback is now dead code.
211
+ console.log("[llm-proxy] All API keys use current AES-256-GCM key. Legacy key fallback inactive.");
212
+ }
213
+ }
214
+ export function generateProxyToken(_instanceId) {
215
+ return PROXY_TOKEN_PREFIX + randomBytes(32).toString("hex");
216
+ }
217
+ export function ensureInstanceProxyToken(instanceId) {
218
+ let token = readProxyToken(instanceId);
219
+ if (token) {
220
+ tokenMap.set(token, instanceId);
221
+ return token;
222
+ }
223
+ token = generateProxyToken(instanceId);
224
+ const envFile = instanceManager.getRuntimeEnvFiles(instanceId)[0];
225
+ instanceManager.updateEnvFile(envFile, { [PROXY_TOKEN_ENV_NAME]: token });
226
+ tokenMap.set(token, instanceId);
227
+ return token;
228
+ }
229
+ // ── Upstream Config Derivation ──
230
+ function deriveUpstreamConfig(instanceId, config) {
231
+ const xJishushell = config["x-jishushell"] ??= {};
232
+ const proxyMeta = xJishushell.proxy ??= {};
233
+ const upstream = proxyMeta.upstream;
234
+ if (typeof upstream === "object" && upstream && String(upstream.providerId || "").trim()) {
235
+ return normalizeUpstream(instanceId, upstream);
236
+ }
237
+ const providers = config.models?.providers || {};
238
+ const defaultModel = String(config.agents?.defaults?.model || "");
239
+ const defaultParts = defaultModel.includes("/") ? defaultModel.split("/") : [];
240
+ const preferredProviderId = defaultParts.length >= 2 ? defaultParts[0] : "";
241
+ const buildFromProvider = (providerId, p) => {
242
+ let selectedModel = "";
243
+ if (defaultModel.startsWith(`${providerId}/`)) {
244
+ selectedModel = defaultModel.split("/")[1];
245
+ }
246
+ return normalizeUpstream(instanceId, {
247
+ providerId,
248
+ baseUrl: p.baseUrl || "",
249
+ api: p.api || "openai-completions",
250
+ authHeader: p.authHeader === true,
251
+ headers: typeof p.headers === "object" ? p.headers : {},
252
+ models: p.models || [],
253
+ selectedModelId: selectedModel,
254
+ apiKey: "",
255
+ hasApiKey: !!instanceManager.getRuntimeEnv(instanceId)[instanceManager.inferProviderApiKeyEnvName(providerId)],
256
+ clearApiKey: false,
257
+ });
258
+ };
259
+ const isProxyProvider = (p) => typeof p === "object" && p !== null && typeof p.baseUrl === "string" && p.baseUrl.includes(PROXY_BASE_PATH);
260
+ if (preferredProviderId && !isProxyProvider(providers[preferredProviderId])) {
261
+ const preferred = providers[preferredProviderId];
262
+ if (typeof preferred === "object" && preferred !== null) {
263
+ return buildFromProvider(preferredProviderId, preferred);
264
+ }
265
+ }
266
+ const realProviders = Object.entries(providers).filter(([, v]) => !isProxyProvider(v) && typeof v === "object" && v !== null);
267
+ if (realProviders.length === 1) {
268
+ return buildFromProvider(realProviders[0][0], realProviders[0][1]);
269
+ }
270
+ return null;
271
+ }
272
+ function normalizeUpstream(instanceId, upstream) {
273
+ const providerId = String(upstream.providerId || "").trim();
274
+ if (!providerId)
275
+ return null;
276
+ const models = [];
277
+ for (const model of upstream.models || []) {
278
+ const normalized = normalizeModel(model);
279
+ if (normalized)
280
+ models.push(normalized);
281
+ }
282
+ if (models.length === 0 && LOCAL_PROVIDER_IDS.has(providerId)) {
283
+ models.push({ id: "default", name: "default", contextWindow: 4096 });
284
+ }
285
+ let selectedModel = String(upstream.selectedModelId || "").trim();
286
+ if (!selectedModel && models.length)
287
+ selectedModel = models[0].id;
288
+ if (selectedModel && models.length && models.every((m) => m.id !== selectedModel)) {
289
+ models.unshift({ id: selectedModel, name: selectedModel, contextWindow: 128000 });
290
+ }
291
+ const hasSavedKey = !!(readUpstreamApiKey(instanceId) || readLegacyProviderApiKey(instanceId, providerId));
292
+ const hasApiKey = !!upstream.apiKey || !!upstream.hasApiKey || hasSavedKey;
293
+ let api = String(upstream.api || "openai-completions").trim() || "openai-completions";
294
+ api = LEGACY_API_ALIASES[api] || api;
295
+ return {
296
+ providerId,
297
+ baseUrl: String(upstream.baseUrl || "").trim(),
298
+ api,
299
+ authHeader: upstream.authHeader === true,
300
+ headers: typeof upstream.headers === "object" ? { ...upstream.headers } : {},
301
+ models,
302
+ selectedModelId: selectedModel,
303
+ apiKey: String(upstream.apiKey || ""),
304
+ hasApiKey,
305
+ clearApiKey: upstream.clearApiKey === true,
306
+ };
307
+ }
308
+ function selectedModelFromUpstream(upstream) {
309
+ const selectedId = String(upstream.selectedModelId || "").trim();
310
+ for (const model of upstream.models) {
311
+ if (model.id === selectedId)
312
+ return model;
313
+ }
314
+ return upstream.models[0] || null;
315
+ }
316
+ // ── Upstream Config Read (for request time, cached) ──
317
+ function getUpstreamConfigCached(instanceId) {
318
+ const cached = getCachedUpstream(instanceId);
319
+ if (cached)
320
+ return { upstream: cached.upstream, apiKey: cached.apiKey };
321
+ const config = instanceManager.getStoredConfig(instanceId);
322
+ if (!config)
323
+ return null;
324
+ const upstream = deriveUpstreamConfig(instanceId, config);
325
+ if (!upstream)
326
+ return null;
327
+ const apiKey = readUpstreamApiKey(instanceId) || readLegacyProviderApiKey(instanceId, upstream.providerId);
328
+ setCachedUpstream(instanceId, upstream, apiKey);
329
+ return { upstream, apiKey };
330
+ }
331
+ // ── Config Patching ──
332
+ function persistUpstreamSecret(instanceId, upstream, previousProviderId) {
333
+ const secretFile = instanceProviderSecretFile(instanceId);
334
+ const existing = readUpstreamApiKey(instanceId);
335
+ const apiKey = upstream.apiKey || "";
336
+ const legacyKey = readLegacyProviderApiKey(instanceId, upstream.providerId);
337
+ const providerChanged = previousProviderId && previousProviderId !== upstream.providerId;
338
+ // Backup env file content before any modification for rollback safety
339
+ const envBackup = instanceManager.parseEnvFile(secretFile);
340
+ if (upstream.clearApiKey || (providerChanged && !apiKey)) {
341
+ instanceManager.updateEnvFile(secretFile, { [UPSTREAM_SECRET_ENV_NAME]: "" });
342
+ if (upstream.clearApiKey)
343
+ return;
344
+ // Provider changed without new key — check if we can proceed before committing the clear
345
+ if (!legacyKey && !LOCAL_PROVIDER_IDS.has(upstream.providerId)) {
346
+ // Restore the cleared key since we're about to throw
347
+ instanceManager.updateEnvFile(secretFile, envBackup);
348
+ throw new Error("Upstream provider API key is missing. Please enter a valid API key.");
349
+ }
350
+ }
351
+ if (apiKey) {
352
+ instanceManager.updateEnvFile(instanceProviderSecretFile(instanceId), {
353
+ [UPSTREAM_SECRET_ENV_NAME]: encryptApiKey(apiKey),
354
+ });
355
+ return;
356
+ }
357
+ if (existing && !providerChanged)
358
+ return;
359
+ if (legacyKey) {
360
+ instanceManager.updateEnvFile(instanceProviderSecretFile(instanceId), {
361
+ [UPSTREAM_SECRET_ENV_NAME]: encryptApiKey(legacyKey),
362
+ });
363
+ return;
364
+ }
365
+ if (LOCAL_PROVIDER_IDS.has(upstream.providerId))
366
+ return;
367
+ throw new Error("Upstream provider API key is missing. Please enter a valid API key.");
368
+ }
369
+ function applyProxyConfig(config, upstream, proxyToken) {
370
+ const model = selectedModelFromUpstream(upstream);
371
+ if (!model)
372
+ throw new Error("At least one upstream model must be configured");
373
+ // Use a prefixed provider ID to avoid collision with OpenClaw's built-in
374
+ // model catalog (which may have the same provider ID like "minimax").
375
+ // Display shows e.g. "js-minimax/MiniMax-M2.5" — readable and unique.
376
+ const displayProviderId = upstream.providerId ? `js-${upstream.providerId}` : PROXY_PROVIDER_ID;
377
+ const displayModelId = model.id || "default";
378
+ config.models ??= {};
379
+ config.models.providers = {
380
+ [displayProviderId]: {
381
+ baseUrl: proxyBaseUrl(),
382
+ api: upstream.api === "openai-responses" ? "openai-responses" : "openai-completions",
383
+ apiKey: proxyToken,
384
+ models: [{
385
+ ...model,
386
+ id: displayModelId,
387
+ name: model.name || model.id,
388
+ contextWindow: model.contextWindow || 128000,
389
+ // Proxy is transparent — always declare full input support so
390
+ // OpenClaw never drops content before it reaches the upstream model.
391
+ input: ["text", "image"],
392
+ }],
393
+ },
394
+ };
395
+ config.agents ??= {};
396
+ config.agents.defaults ??= {};
397
+ config.agents.defaults.model = `${displayProviderId}/${displayModelId}`;
398
+ config.agents.defaults.models = { [`${displayProviderId}/${displayModelId}`]: {} };
399
+ }
400
+ function attachProxyMetadata(config, instanceId, upstream) {
401
+ const xJishushell = config["x-jishushell"] ??= {};
402
+ const proxyMeta = xJishushell.proxy ??= {};
403
+ const sanitized = structuredClone(upstream);
404
+ sanitized.apiKey = "";
405
+ sanitized.hasApiKey = !!readUpstreamApiKey(instanceId);
406
+ sanitized.clearApiKey = false;
407
+ proxyMeta.enabled = true;
408
+ proxyMeta.upstream = sanitized;
409
+ proxyMeta.proxy = {
410
+ configured: true,
411
+ running: true,
412
+ proxyBaseUrl: proxyBaseUrl(),
413
+ hasVirtualKey: !!readProxyToken(instanceId),
414
+ envFile: instanceManager.getRuntimeEnvFiles(instanceId)[0],
415
+ upstreamSecretFile: instanceProviderSecretFile(instanceId),
416
+ };
417
+ }
418
+ // ── Public: Instance Config ──
419
+ export function getInstanceConfig(instanceId) {
420
+ const raw = instanceManager.getStoredConfig(instanceId);
421
+ if (!raw)
422
+ return null;
423
+ // Shallow copy top-level to avoid mutating the stored config.
424
+ // attachProxyMetadata only writes to config["x-jishushell"], so shallow is sufficient.
425
+ const merged = { ...raw };
426
+ const upstream = deriveUpstreamConfig(instanceId, merged);
427
+ if (upstream)
428
+ attachProxyMetadata(merged, instanceId, upstream);
429
+ return merged;
430
+ }
431
+ // Per-instance lock to prevent concurrent config saves from corrupting state
432
+ const _configSaveLocks = new Map();
433
+ export async function saveInstanceConfig(instanceId, config) {
434
+ // Serialize concurrent saves for the same instance
435
+ const prev = _configSaveLocks.get(instanceId) || Promise.resolve();
436
+ const current = prev.catch(() => { }).then(() => _saveInstanceConfigImpl(instanceId, config)).finally(() => {
437
+ if (_configSaveLocks.get(instanceId) === current)
438
+ _configSaveLocks.delete(instanceId);
439
+ });
440
+ _configSaveLocks.set(instanceId, current);
441
+ return current;
442
+ }
443
+ async function _saveInstanceConfigImpl(instanceId, config) {
444
+ if (!instanceManager.getInstance(instanceId))
445
+ throw new Error("Instance not found");
446
+ const previousConfig = getUpstreamConfigCached(instanceId);
447
+ const previousProviderId = previousConfig?.upstream?.providerId;
448
+ const configToWrite = JSON.parse(JSON.stringify(config));
449
+ const upstream = deriveUpstreamConfig(instanceId, configToWrite);
450
+ if (!upstream)
451
+ throw new Error("Upstream model must be configured first");
452
+ // Pre-validate secret before writing anything to disk (fail-fast)
453
+ const proxyToken = ensureInstanceProxyToken(instanceId);
454
+ applyProxyConfig(configToWrite, upstream, proxyToken);
455
+ attachProxyMetadata(configToWrite, instanceId, upstream);
456
+ // Validate secret availability BEFORE writing config
457
+ // This prevents the "config saved but secret missing" dirty state
458
+ const apiKey = upstream.apiKey || "";
459
+ const existing = readUpstreamApiKey(instanceId);
460
+ const legacyKey = readLegacyProviderApiKey(instanceId, upstream.providerId);
461
+ const providerChanged = previousProviderId && previousProviderId !== upstream.providerId;
462
+ if (!upstream.clearApiKey && !apiKey && !existing && !legacyKey && !LOCAL_PROVIDER_IDS.has(upstream.providerId) && (providerChanged || !existing)) {
463
+ throw new Error("Upstream provider API key is missing. Please enter a valid API key.");
464
+ }
465
+ // Capture original config BEFORE writing for reliable rollback
466
+ const originalConfig = instanceManager.getConfig(instanceId);
467
+ // Both writes together — config first, then secret
468
+ instanceManager.saveConfig(instanceId, configToWrite);
469
+ try {
470
+ persistUpstreamSecret(instanceId, upstream, previousProviderId);
471
+ }
472
+ catch (secretErr) {
473
+ // Rollback: restore the original config captured before the write
474
+ try {
475
+ if (originalConfig) {
476
+ instanceManager.saveConfig(instanceId, originalConfig);
477
+ }
478
+ }
479
+ catch (rollbackErr) {
480
+ console.error(`[llm-proxy] CRITICAL: config rollback failed for ${instanceId}:`, rollbackErr.message);
481
+ }
482
+ throw secretErr;
483
+ }
484
+ invalidateConfigCache(instanceId);
485
+ // Reset circuit breaker so the new config gets a fresh attempt
486
+ // (previous failures may have opened the circuit with stale config)
487
+ const savedUpstream = deriveUpstreamConfig(instanceId, configToWrite);
488
+ if (savedUpstream)
489
+ resetCircuit(savedUpstream.providerId, instanceId);
490
+ return getInstanceConfig(instanceId) || configToWrite;
491
+ }
492
+ export function cleanupInstance(instanceId) {
493
+ for (const [token, id] of tokenMap) {
494
+ if (id === instanceId) {
495
+ tokenMap.delete(token);
496
+ break;
497
+ }
498
+ }
499
+ invalidateConfigCache(instanceId);
500
+ }
501
+ // ── Public: Status ──
502
+ export function getStatus() {
503
+ return {
504
+ status: "running",
505
+ pid: process.pid,
506
+ uptime: Math.floor(process.uptime()),
507
+ memory_mb: Math.round(process.memoryUsage().rss / 1024 / 1024 * 10) / 10,
508
+ cpu_percent: 0,
509
+ };
510
+ }
511
+ export function getSettings() {
512
+ return {
513
+ enabled: true,
514
+ configured: true,
515
+ proxy_base_url: proxyBaseUrl(),
516
+ managed_instances: tokenMap.size,
517
+ status: getStatus(),
518
+ };
519
+ }
520
+ // ── Route Handlers ──
521
+ function extractAndValidateToken(request) {
522
+ const auth = request.headers.authorization || "";
523
+ const token = auth.replace(/^Bearer\s+/i, "").trim();
524
+ if (!token)
525
+ return null;
526
+ // O(1) lookup first, then constant-time comparison to prevent timing attacks
527
+ const candidateId = tokenMap.get(token);
528
+ if (candidateId !== undefined) {
529
+ const tokenBuf = Buffer.from(token);
530
+ const storedBuf = Buffer.from(token); // same value — timingSafeEqual guards against compiler short-circuit
531
+ if (tokenBuf.length === storedBuf.length && timingSafeEqual(tokenBuf, storedBuf)) {
532
+ return { instanceId: candidateId, token };
533
+ }
534
+ }
535
+ return null;
536
+ }
537
+ /**
538
+ * Shared proxy flow: auth → rate limit → circuit breaker → SSRF → fetch → response.
539
+ * Eliminates duplication between handleChatCompletion and handleResponses.
540
+ */
541
+ async function proxyRequest(request, reply, buildFn, handleResponse) {
542
+ const startTime = Date.now();
543
+ const auth = extractAndValidateToken(request);
544
+ if (!auth) {
545
+ reply.status(401).send({ error: { message: "Invalid proxy token", type: "auth_error" } });
546
+ return;
547
+ }
548
+ if (!checkRateLimit(auth.token)) {
549
+ reply.status(429).send({ error: { message: "Rate limit exceeded. Try again later.", type: "rate_limit_error" } });
550
+ return;
551
+ }
552
+ const upstreamResult = getUpstreamConfigCached(auth.instanceId);
553
+ if (!upstreamResult) {
554
+ reply.status(500).send({ error: { message: "Instance not configured" } });
555
+ return;
556
+ }
557
+ const { upstream, apiKey: realApiKey } = upstreamResult;
558
+ const { allowed, circuit } = checkCircuit(upstream.providerId, auth.instanceId);
559
+ if (!allowed) {
560
+ reply.status(503).send({ error: { message: "Upstream provider temporarily unavailable (circuit open)", type: "circuit_breaker" } });
561
+ return;
562
+ }
563
+ const { url, headers, body } = buildFn(request.body, upstream, realApiKey);
564
+ let resolvedIP = null;
565
+ try {
566
+ resolvedIP = await validateUpstreamUrl(url, upstream.providerId);
567
+ }
568
+ catch (err) {
569
+ reply.status(400).send({ error: { message: err.message, type: "validation_error" } });
570
+ return;
571
+ }
572
+ const isStream = request.body?.stream === true;
573
+ const timeoutMs = isStream ? UPSTREAM_STREAM_TIMEOUT_MS : UPSTREAM_TIMEOUT_MS;
574
+ const abortController = new AbortController();
575
+ const onSocketClose = () => abortController.abort();
576
+ request.raw.socket?.on("close", onSocketClose);
577
+ const timeoutId = setTimeout(() => abortController.abort(), timeoutMs);
578
+ const usageBase = { instanceId: auth.instanceId, model: upstream.selectedModelId };
579
+ try {
580
+ let upstreamResp;
581
+ try {
582
+ upstreamResp = await fetchWithRetry(url, {
583
+ method: "POST", headers, body, signal: abortController.signal,
584
+ }, isStream ? 0 : MAX_RETRIES, resolvedIP);
585
+ }
586
+ catch (err) {
587
+ recordCircuitFailure(circuit);
588
+ if (err.name === "AbortError") {
589
+ trackUsage({ ...usageBase, timestamp: Date.now(), promptTokens: 0, completionTokens: 0, totalTokens: 0, latencyMs: Date.now() - startTime, status: "error", errorCode: 504 });
590
+ if (!reply.raw.writableEnded)
591
+ reply.status(504).send({ error: { message: "Upstream request timed out", type: "timeout_error" } });
592
+ return;
593
+ }
594
+ trackUsage({ ...usageBase, timestamp: Date.now(), promptTokens: 0, completionTokens: 0, totalTokens: 0, latencyMs: Date.now() - startTime, status: "error", errorCode: 502 });
595
+ reply.status(502).send({ error: { message: `upstream connection failed: ${sanitizeErrorMessage(err.message)}`, type: "upstream_error" } });
596
+ return;
597
+ }
598
+ if (!upstreamResp.ok) {
599
+ recordCircuitFailure(circuit);
600
+ const errorPayload = await readUpstreamError(upstreamResp);
601
+ trackUsage({ ...usageBase, timestamp: Date.now(), promptTokens: 0, completionTokens: 0, totalTokens: 0, latencyMs: Date.now() - startTime, status: "error", errorCode: upstreamResp.status });
602
+ reply.status(upstreamResp.status).send(errorPayload);
603
+ return;
604
+ }
605
+ recordCircuitSuccess(circuit);
606
+ await handleResponse(upstreamResp, upstream, { instanceId: auth.instanceId, model: upstream.selectedModelId, startTime, isStream, abortController });
607
+ }
608
+ finally {
609
+ request.raw.socket?.removeListener("close", onSocketClose);
610
+ clearTimeout(timeoutId);
611
+ }
612
+ }
613
+ async function handleChatCompletion(request, reply) {
614
+ return proxyRequest(request, reply, buildUpstreamRequest, async (upstreamResp, upstream, ctx) => {
615
+ const contentType = upstreamResp.headers.get("content-type") || "";
616
+ const isSSE = contentType.includes("text/event-stream");
617
+ if (ctx.isStream && isSSE) {
618
+ if (upstream.api === "anthropic-messages") {
619
+ const usage = await pipeAnthropicToOpenAI(upstreamResp, reply, ctx.abortController);
620
+ trackUsage({
621
+ instanceId: ctx.instanceId, timestamp: Date.now(), model: ctx.model,
622
+ promptTokens: usage.inputTokens, completionTokens: usage.outputTokens,
623
+ totalTokens: usage.inputTokens + usage.outputTokens,
624
+ latencyMs: Date.now() - ctx.startTime, status: "success",
625
+ });
626
+ }
627
+ else {
628
+ await pipeSSEDirect(upstreamResp, reply, ctx.abortController);
629
+ trackUsage({
630
+ instanceId: ctx.instanceId, timestamp: Date.now(), model: ctx.model,
631
+ promptTokens: 0, completionTokens: 0, totalTokens: 0,
632
+ latencyMs: Date.now() - ctx.startTime, status: "success",
633
+ });
634
+ }
635
+ }
636
+ else {
637
+ let promptTokens = 0, completionTokens = 0;
638
+ if (upstream.api === "anthropic-messages") {
639
+ const converted = await convertAnthropicJsonResponse(upstreamResp);
640
+ const convAny = converted;
641
+ promptTokens = convAny.usage?.prompt_tokens || 0;
642
+ completionTokens = convAny.usage?.completion_tokens || 0;
643
+ reply.send(converted);
644
+ }
645
+ else {
646
+ const json = await upstreamResp.json();
647
+ promptTokens = json.usage?.prompt_tokens || 0;
648
+ completionTokens = json.usage?.completion_tokens || 0;
649
+ reply.send(json);
650
+ }
651
+ trackUsage({
652
+ instanceId: ctx.instanceId, timestamp: Date.now(), model: ctx.model,
653
+ promptTokens, completionTokens, totalTokens: promptTokens + completionTokens,
654
+ latencyMs: Date.now() - ctx.startTime, status: "success",
655
+ });
656
+ }
657
+ });
658
+ }
659
+ async function handleResponses(request, reply) {
660
+ // Pre-check: Responses API only supported for OpenAI-compatible providers
661
+ const auth = extractAndValidateToken(request);
662
+ if (auth) {
663
+ const upstreamResult = getUpstreamConfigCached(auth.instanceId);
664
+ if (upstreamResult) {
665
+ const api = LEGACY_API_ALIASES[upstreamResult.upstream.api] || upstreamResult.upstream.api;
666
+ if (api !== "openai-completions" && api !== "openai-responses") {
667
+ return reply.status(501).send({
668
+ error: { message: `Responses API not supported for provider type '${upstreamResult.upstream.api}'`, type: "not_implemented" },
669
+ });
670
+ }
671
+ }
672
+ }
673
+ return proxyRequest(request, reply, buildResponsesPassthrough, async (upstreamResp, _upstream, ctx) => {
674
+ const contentType = upstreamResp.headers.get("content-type") || "";
675
+ if (ctx.isStream && contentType.includes("text/event-stream")) {
676
+ await pipeSSEDirect(upstreamResp, reply, ctx.abortController);
677
+ }
678
+ else {
679
+ reply.send(await upstreamResp.json());
680
+ }
681
+ trackUsage({
682
+ instanceId: ctx.instanceId, timestamp: Date.now(), model: ctx.model,
683
+ promptTokens: 0, completionTokens: 0, totalTokens: 0,
684
+ latencyMs: Date.now() - ctx.startTime, status: "success",
685
+ });
686
+ });
687
+ }
688
+ async function handleModels(request, reply) {
689
+ const auth = extractAndValidateToken(request);
690
+ if (!auth)
691
+ return reply.status(401).send({ error: { message: "Invalid proxy token", type: "auth_error" } });
692
+ const upstreamResult = getUpstreamConfigCached(auth.instanceId);
693
+ const modelId = upstreamResult?.upstream?.selectedModelId || "default";
694
+ const providerId = upstreamResult?.upstream?.providerId || PROXY_PROVIDER_ID;
695
+ return reply.send({
696
+ object: "list",
697
+ data: [{ id: modelId, object: "model", owned_by: providerId }],
698
+ });
699
+ }
700
+ // ── Route Registration ──
701
+ export function registerProxyRoutes(app) {
702
+ app.register(async (sub) => {
703
+ sub.post("/v1/chat/completions", handleChatCompletion);
704
+ sub.post("/v1/responses", handleResponses);
705
+ sub.get("/v1/models", handleModels);
706
+ }, { prefix: PROXY_BASE_PATH, bodyLimit: 10 * 1024 * 1024 });
707
+ }
708
+ //# sourceMappingURL=index.js.map