jishushell 0.0.1 → 0.4.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/LICENSE +202 -0
- package/README.md +36 -0
- package/THIRD-PARTY-NOTICES +387 -0
- package/dist/auth.d.ts +6 -0
- package/dist/auth.js +88 -0
- package/dist/auth.js.map +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +290 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +24 -0
- package/dist/config.js +226 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +3 -0
- package/dist/constants.js +15 -0
- package/dist/constants.js.map +1 -0
- package/dist/control.d.ts +44 -0
- package/dist/control.js +1359 -0
- package/dist/control.js.map +1 -0
- package/dist/crypto-shim.d.ts +1 -0
- package/dist/crypto-shim.js +2 -0
- package/dist/crypto-shim.js.map +1 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +937 -0
- package/dist/doctor.js.map +1 -0
- package/dist/install.d.ts +27 -0
- package/dist/install.js +570 -0
- package/dist/install.js.map +1 -0
- package/dist/routes/auth.d.ts +4 -0
- package/dist/routes/auth.js +151 -0
- package/dist/routes/auth.js.map +1 -0
- package/dist/routes/instances.d.ts +2 -0
- package/dist/routes/instances.js +1303 -0
- package/dist/routes/instances.js.map +1 -0
- package/dist/routes/setup.d.ts +2 -0
- package/dist/routes/setup.js +139 -0
- package/dist/routes/setup.js.map +1 -0
- package/dist/routes/system.d.ts +2 -0
- package/dist/routes/system.js +102 -0
- package/dist/routes/system.js.map +1 -0
- package/dist/server.d.ts +6 -0
- package/dist/server.js +392 -0
- package/dist/server.js.map +1 -0
- package/dist/services/instance-manager.d.ts +67 -0
- package/dist/services/instance-manager.js +1319 -0
- package/dist/services/instance-manager.js.map +1 -0
- package/dist/services/llm-proxy/adapters.d.ts +3 -0
- package/dist/services/llm-proxy/adapters.js +309 -0
- package/dist/services/llm-proxy/adapters.js.map +1 -0
- package/dist/services/llm-proxy/circuit-breaker.d.ts +9 -0
- package/dist/services/llm-proxy/circuit-breaker.js +73 -0
- package/dist/services/llm-proxy/circuit-breaker.js.map +1 -0
- package/dist/services/llm-proxy/encryption.d.ts +6 -0
- package/dist/services/llm-proxy/encryption.js +61 -0
- package/dist/services/llm-proxy/encryption.js.map +1 -0
- package/dist/services/llm-proxy/index.d.ts +24 -0
- package/dist/services/llm-proxy/index.js +708 -0
- package/dist/services/llm-proxy/index.js.map +1 -0
- package/dist/services/llm-proxy/rate-limiter.d.ts +1 -0
- package/dist/services/llm-proxy/rate-limiter.js +39 -0
- package/dist/services/llm-proxy/rate-limiter.js.map +1 -0
- package/dist/services/llm-proxy/sse.d.ts +10 -0
- package/dist/services/llm-proxy/sse.js +378 -0
- package/dist/services/llm-proxy/sse.js.map +1 -0
- package/dist/services/llm-proxy/ssrf.d.ts +16 -0
- package/dist/services/llm-proxy/ssrf.js +185 -0
- package/dist/services/llm-proxy/ssrf.js.map +1 -0
- package/dist/services/llm-proxy/types.d.ts +52 -0
- package/dist/services/llm-proxy/types.js +2 -0
- package/dist/services/llm-proxy/types.js.map +1 -0
- package/dist/services/llm-proxy/usage.d.ts +12 -0
- package/dist/services/llm-proxy/usage.js +108 -0
- package/dist/services/llm-proxy/usage.js.map +1 -0
- package/dist/services/nomad-manager.d.ts +22 -0
- package/dist/services/nomad-manager.js +828 -0
- package/dist/services/nomad-manager.js.map +1 -0
- package/dist/services/plugin-installer.d.ts +22 -0
- package/dist/services/plugin-installer.js +102 -0
- package/dist/services/plugin-installer.js.map +1 -0
- package/dist/services/process-manager.d.ts +25 -0
- package/dist/services/process-manager.js +531 -0
- package/dist/services/process-manager.js.map +1 -0
- package/dist/services/setup-manager.d.ts +93 -0
- package/dist/services/setup-manager.js +1922 -0
- package/dist/services/setup-manager.js.map +1 -0
- package/dist/services/system-monitor.d.ts +1 -0
- package/dist/services/system-monitor.js +79 -0
- package/dist/services/system-monitor.js.map +1 -0
- package/dist/services/telemetry/activation.d.ts +12 -0
- package/dist/services/telemetry/activation.js +75 -0
- package/dist/services/telemetry/activation.js.map +1 -0
- package/dist/services/telemetry/client.d.ts +21 -0
- package/dist/services/telemetry/client.js +47 -0
- package/dist/services/telemetry/client.js.map +1 -0
- package/dist/services/telemetry/device-fingerprint.d.ts +18 -0
- package/dist/services/telemetry/device-fingerprint.js +123 -0
- package/dist/services/telemetry/device-fingerprint.js.map +1 -0
- package/dist/services/telemetry/heartbeat.d.ts +13 -0
- package/dist/services/telemetry/heartbeat.js +81 -0
- package/dist/services/telemetry/heartbeat.js.map +1 -0
- package/dist/services/telemetry/index.d.ts +3 -0
- package/dist/services/telemetry/index.js +4 -0
- package/dist/services/telemetry/index.js.map +1 -0
- package/dist/types.d.ts +51 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/safe-json.d.ts +2 -0
- package/dist/utils/safe-json.js +80 -0
- package/dist/utils/safe-json.js.map +1 -0
- package/dist/utils/ttl-cache.d.ts +29 -0
- package/dist/utils/ttl-cache.js +77 -0
- package/dist/utils/ttl-cache.js.map +1 -0
- package/install/jishu-install.sh +2920 -0
- package/install/jishu-uninstall.sh +811 -0
- package/install/post-install.sh +110 -0
- package/install/post-uninstall.sh +46 -0
- package/package.json +57 -8
- package/public/assets/Dashboard-CAOQDYDR.js +1 -0
- package/public/assets/InitPassword-CkehIkJG.js +1 -0
- package/public/assets/InstanceDetail-CzW2S95J.js +14 -0
- package/public/assets/Login-RkjzTNWg.js +1 -0
- package/public/assets/NewInstance-DdbErdjA.js +1 -0
- package/public/assets/Settings-BUD7zwv9.js +1 -0
- package/public/assets/Setup-RRTIERGG.js +1 -0
- package/public/assets/index-77Ug7feY.css +1 -0
- package/public/assets/index-DfRnVUQR.js +16 -0
- package/public/assets/providers-lBSOjUWy.js +1 -0
- package/public/assets/usePolling-CqQ8hrNc.js +1 -0
- package/public/assets/vendor-i18n-Bvxxh8Di.js +9 -0
- package/public/assets/vendor-react-DONn7uBV.js +59 -0
- package/public/index.html +15 -0
- package/scripts/build-image.sh +55 -0
- package/scripts/run.sh +310 -0
- package/scripts/setup-pi.sh +80 -0
- package/scripts/start-feishu1.js +46 -0
- package/index.js +0 -0
- 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
|