@nanogpt/private-mode 0.2.2 → 0.2.5
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/README.md +3 -3
- package/lib/requestTransforms.js +630 -4
- package/lib/secureClientLifecycle.js +20 -5
- package/lib/server.js +65 -3
- package/lib/serverErrorNormalization.js +107 -6
- package/models/private-tee.json +8 -8
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -25,14 +25,14 @@ const client = new OpenAI({
|
|
|
25
25
|
});
|
|
26
26
|
|
|
27
27
|
const response = await client.chat.completions.create({
|
|
28
|
-
model: "private/
|
|
28
|
+
model: "private/glm-5-2",
|
|
29
29
|
messages: [{ role: "user", content: "Hello" }],
|
|
30
30
|
});
|
|
31
31
|
```
|
|
32
32
|
|
|
33
33
|
The local proxy verifies TEE attestation, encrypts request bodies with EHBP, sends ciphertext through NanoGPT, decrypts encrypted responses locally, and returns normal OpenAI JSON to the calling app.
|
|
34
34
|
|
|
35
|
-
After a long idle period (five minutes by default), the proxy re-verifies attestation and creates a fresh encrypted transport before the next request. This makes laptop sleep/resume safe without requiring a proxy restart. Set `NANOGPT_PRIVATE_CLIENT_IDLE_RESET_MS` to a positive millisecond value to adjust the idle threshold.
|
|
35
|
+
After a long idle period (five minutes by default), the proxy re-verifies attestation and creates a fresh encrypted transport before the next request. This makes laptop sleep/resume safe without requiring a proxy restart. Set `NANOGPT_PRIVATE_CLIENT_IDLE_RESET_MS` to a positive millisecond value to adjust the idle threshold, or `0` to disable idle resets.
|
|
36
36
|
|
|
37
37
|
NanoGPT can see account identity, selected private model, selected TEE target metadata, timing, sizes, status, and usage metadata. NanoGPT cannot read the prompt or completion body for supported private models.
|
|
38
38
|
|
|
@@ -54,7 +54,7 @@ GET http://127.0.0.1:8787/v1/private-mode/attestation
|
|
|
54
54
|
|
|
55
55
|
Supported private model IDs include:
|
|
56
56
|
|
|
57
|
-
- `private/kimi-
|
|
57
|
+
- `private/kimi-k3`
|
|
58
58
|
- `private/glm-5-1`
|
|
59
59
|
- `private/glm-5-1-thinking`
|
|
60
60
|
- `private/glm-5-2`
|
package/lib/requestTransforms.js
CHANGED
|
@@ -8,9 +8,25 @@ const ASSISTANT_REASONING_MESSAGE_FIELDS = [
|
|
|
8
8
|
];
|
|
9
9
|
const MAX_TOKEN_ALIAS_FIELDS = [
|
|
10
10
|
'max_completion_tokens',
|
|
11
|
+
'max_output_tokens',
|
|
11
12
|
'maxCompletionTokens',
|
|
12
13
|
'maxTokens',
|
|
13
14
|
];
|
|
15
|
+
const KIMI_K3_STANDARD_DEFAULT_MAX_COMPLETION_TOKENS = 16_384;
|
|
16
|
+
const KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS = 65_536;
|
|
17
|
+
const KIMI_K3_MAX_COMPLETION_TOKENS = 1_048_576;
|
|
18
|
+
const KIMI_K3_CONTEXT_WINDOW_TOKENS = 1_048_576;
|
|
19
|
+
const KIMI_K3_CONTEXT_SAFETY_MARGIN_TOKENS = 10;
|
|
20
|
+
const KIMI_K3_ESTIMATED_TEXT_BYTES_PER_TOKEN = 3;
|
|
21
|
+
const KIMI_K3_HIGH_ENTROPY_TEXT_MIN_LENGTH = 2_048;
|
|
22
|
+
const KIMI_K3_HIGH_ENTROPY_TOKENS_PER_BYTE = 0.75;
|
|
23
|
+
const KIMI_K3_IMAGE_TOKENS = 85;
|
|
24
|
+
const KIMI_K3_DOCUMENT_TOKENS = 85;
|
|
25
|
+
const KIMI_K3_AUDIO_TOKENS = 32_768;
|
|
26
|
+
const KIMI_K3_AUDIO_URL_TOKENS = 4_096;
|
|
27
|
+
const KIMI_K3_VIDEO_TOKENS = 65_536;
|
|
28
|
+
const KIMI_K3_REASONING_EFFORT_LEVELS = new Set(['low', 'high', 'max']);
|
|
29
|
+
const KIMI_K3_TEXT_ENCODER = new TextEncoder();
|
|
14
30
|
const PRIVATE_TINFOIL_CHAT_COMPLETION_BODY_FIELDS = new Set([
|
|
15
31
|
'chat_template_kwargs',
|
|
16
32
|
'frequency_penalty',
|
|
@@ -44,6 +60,149 @@ function isPlainObject(value) {
|
|
|
44
60
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
45
61
|
}
|
|
46
62
|
|
|
63
|
+
function estimateKimiK3TextTokens(value) {
|
|
64
|
+
if (typeof value !== 'string' || value.length === 0) return 0;
|
|
65
|
+
const utf8Bytes = KIMI_K3_TEXT_ENCODER.encode(value).byteLength;
|
|
66
|
+
const ordinaryEstimate = Math.ceil(utf8Bytes / KIMI_K3_ESTIMATED_TEXT_BYTES_PER_TOKEN);
|
|
67
|
+
if (value.length < KIMI_K3_HIGH_ENTROPY_TEXT_MIN_LENGTH) return ordinaryEstimate;
|
|
68
|
+
|
|
69
|
+
let ascii = 0;
|
|
70
|
+
let whitespace = 0;
|
|
71
|
+
const uniqueChars = new Set();
|
|
72
|
+
let currentRun = 0;
|
|
73
|
+
let longestRun = 0;
|
|
74
|
+
let previous = '';
|
|
75
|
+
|
|
76
|
+
for (const char of value) {
|
|
77
|
+
const code = char.charCodeAt(0);
|
|
78
|
+
if (code >= 32 && code <= 126) ascii += 1;
|
|
79
|
+
if (/\s/.test(char)) whitespace += 1;
|
|
80
|
+
uniqueChars.add(char);
|
|
81
|
+
if (char === previous) {
|
|
82
|
+
currentRun += 1;
|
|
83
|
+
} else {
|
|
84
|
+
previous = char;
|
|
85
|
+
currentRun = 1;
|
|
86
|
+
}
|
|
87
|
+
if (currentRun > longestRun) longestRun = currentRun;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const isHighEntropy =
|
|
91
|
+
ascii / value.length >= 0.98 &&
|
|
92
|
+
whitespace / value.length <= 0.02 &&
|
|
93
|
+
uniqueChars.size >= 32 &&
|
|
94
|
+
longestRun / value.length <= 0.02;
|
|
95
|
+
if (!isHighEntropy) return ordinaryEstimate;
|
|
96
|
+
|
|
97
|
+
return Math.max(
|
|
98
|
+
ordinaryEstimate,
|
|
99
|
+
Math.ceil(utf8Bytes * KIMI_K3_HIGH_ENTROPY_TOKENS_PER_BYTE),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function estimateKimiK3ContentTokens(content) {
|
|
104
|
+
if (typeof content === 'string') return estimateKimiK3TextTokens(content);
|
|
105
|
+
if (Array.isArray(content)) {
|
|
106
|
+
return content.reduce((total, part) => total + estimateKimiK3ContentTokens(part), 0);
|
|
107
|
+
}
|
|
108
|
+
if (!isPlainObject(content)) return 0;
|
|
109
|
+
|
|
110
|
+
const type = String(content.type || '').toLowerCase();
|
|
111
|
+
const nestedFile = isPlainObject(content.file) ? content.file : {};
|
|
112
|
+
const nestedSource = isPlainObject(content.source) ? content.source : {};
|
|
113
|
+
const nestedDocument = isPlainObject(content.document) ? content.document : {};
|
|
114
|
+
const mediaType = String(
|
|
115
|
+
content.media_type ||
|
|
116
|
+
content.mime_type ||
|
|
117
|
+
content.file_type ||
|
|
118
|
+
nestedFile.media_type ||
|
|
119
|
+
nestedFile.mime_type ||
|
|
120
|
+
nestedFile.file_type ||
|
|
121
|
+
nestedSource.media_type ||
|
|
122
|
+
nestedSource.mime_type ||
|
|
123
|
+
nestedDocument.media_type ||
|
|
124
|
+
nestedDocument.mime_type ||
|
|
125
|
+
'',
|
|
126
|
+
).trim().toLowerCase();
|
|
127
|
+
const hasVideoMediaValue = [
|
|
128
|
+
content.url,
|
|
129
|
+
content.file_url,
|
|
130
|
+
content.file_data,
|
|
131
|
+
nestedFile.url,
|
|
132
|
+
nestedFile.file_url,
|
|
133
|
+
nestedFile.file_data,
|
|
134
|
+
nestedFile.data,
|
|
135
|
+
nestedSource.url,
|
|
136
|
+
nestedSource.data,
|
|
137
|
+
nestedDocument.url,
|
|
138
|
+
nestedDocument.file_url,
|
|
139
|
+
].some((value) => {
|
|
140
|
+
if (isPlainObject(value)) value = value.url;
|
|
141
|
+
if (typeof value !== 'string') return false;
|
|
142
|
+
const normalized = value.trim().toLowerCase();
|
|
143
|
+
if (normalized.startsWith('data:video/')) return true;
|
|
144
|
+
let pathname = normalized.split(/[?#]/, 1)[0] || normalized;
|
|
145
|
+
try {
|
|
146
|
+
pathname = new URL(value).pathname.toLowerCase();
|
|
147
|
+
} catch {}
|
|
148
|
+
return /\.(?:mp4|mov|mkv|avi|m4v|mpeg|mpg|webm|ogv)$/.test(pathname);
|
|
149
|
+
});
|
|
150
|
+
if (type === 'audio_url' || (!type && content.audio_url)) return KIMI_K3_AUDIO_URL_TOKENS;
|
|
151
|
+
if (type === 'input_audio' || type === 'audio') return KIMI_K3_AUDIO_TOKENS;
|
|
152
|
+
if (
|
|
153
|
+
type === 'video' ||
|
|
154
|
+
type === 'video_url' ||
|
|
155
|
+
type === 'input_video' ||
|
|
156
|
+
mediaType.startsWith('video/') ||
|
|
157
|
+
hasVideoMediaValue ||
|
|
158
|
+
'video_url' in content ||
|
|
159
|
+
'input_video' in content
|
|
160
|
+
) return KIMI_K3_VIDEO_TOKENS;
|
|
161
|
+
if (
|
|
162
|
+
type === 'image' ||
|
|
163
|
+
type === 'image_url' ||
|
|
164
|
+
type === 'input_image' ||
|
|
165
|
+
'image_url' in content
|
|
166
|
+
) return KIMI_K3_IMAGE_TOKENS;
|
|
167
|
+
if (
|
|
168
|
+
type === 'document' ||
|
|
169
|
+
type === 'input_file' ||
|
|
170
|
+
type === 'input_document' ||
|
|
171
|
+
'file_url' in content
|
|
172
|
+
) return KIMI_K3_DOCUMENT_TOKENS;
|
|
173
|
+
|
|
174
|
+
for (const key of ['text', 'content', 'message']) {
|
|
175
|
+
if (typeof content[key] === 'string') return estimateKimiK3TextTokens(content[key]);
|
|
176
|
+
}
|
|
177
|
+
return estimateKimiK3TextTokens(JSON.stringify(content));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function estimatePrivateModeKimiK3PromptTokens(body) {
|
|
181
|
+
let tokens = 3;
|
|
182
|
+
for (const message of Array.isArray(body.messages) ? body.messages : []) {
|
|
183
|
+
if (!isPlainObject(message)) continue;
|
|
184
|
+
tokens += 3;
|
|
185
|
+
tokens += estimateKimiK3TextTokens(message.role);
|
|
186
|
+
tokens += estimateKimiK3TextTokens(message.name);
|
|
187
|
+
tokens += estimateKimiK3ContentTokens(message.content);
|
|
188
|
+
tokens += estimateKimiK3ContentTokens(message.prompt);
|
|
189
|
+
tokens += estimateKimiK3TextTokens(message.reasoning_content);
|
|
190
|
+
if (Array.isArray(message.tools)) {
|
|
191
|
+
tokens += estimateKimiK3TextTokens(JSON.stringify(message.tools));
|
|
192
|
+
}
|
|
193
|
+
if (message.function_call !== undefined) {
|
|
194
|
+
tokens += estimateKimiK3TextTokens(JSON.stringify(message.function_call));
|
|
195
|
+
}
|
|
196
|
+
if (message.tool_calls !== undefined) {
|
|
197
|
+
tokens += estimateKimiK3TextTokens(JSON.stringify(message.tool_calls));
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (Array.isArray(body.tools)) {
|
|
201
|
+
tokens += estimateKimiK3TextTokens(JSON.stringify(body.tools));
|
|
202
|
+
}
|
|
203
|
+
return tokens;
|
|
204
|
+
}
|
|
205
|
+
|
|
47
206
|
function isThinkingEnabled(thinking) {
|
|
48
207
|
if (typeof thinking === 'boolean') return thinking;
|
|
49
208
|
if (!isPlainObject(thinking)) return undefined;
|
|
@@ -123,8 +282,32 @@ function stripPrivateModeReasoningBlocks(value) {
|
|
|
123
282
|
.replace(/◁think▷/g, '<think>')
|
|
124
283
|
.replace(/◁\/think▷/g, '</think>')
|
|
125
284
|
.replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
|
|
285
|
+
.replace(/<thinking>[\s\S]*?<\/thinking>\s*/gi, '')
|
|
126
286
|
.replace(/<previous_reasoning>[\s\S]*?<\/previous_reasoning>\s*/gi, '')
|
|
127
|
-
.
|
|
287
|
+
.replace(/<(?:think|thinking|previous_reasoning)>[\s\S]*$/i, '');
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function extractPrivateModeReasoningBlocks(value) {
|
|
291
|
+
const textValues = typeof value === 'string'
|
|
292
|
+
? [value]
|
|
293
|
+
: Array.isArray(value)
|
|
294
|
+
? value.flatMap((part) => (
|
|
295
|
+
isPlainObject(part) && part.type === 'text' && typeof part.text === 'string'
|
|
296
|
+
? [part.text]
|
|
297
|
+
: []
|
|
298
|
+
))
|
|
299
|
+
: [];
|
|
300
|
+
const reasoningParts = textValues.flatMap((text) => {
|
|
301
|
+
const normalized = text
|
|
302
|
+
.replace(/◁think▷/g, '<think>')
|
|
303
|
+
.replace(/◁\/think▷/g, '</think>');
|
|
304
|
+
return Array.from(
|
|
305
|
+
normalized.matchAll(/<(think|thinking|previous_reasoning)>([\s\S]*?)<\/\1>/gi),
|
|
306
|
+
(match) => match[2],
|
|
307
|
+
);
|
|
308
|
+
});
|
|
309
|
+
const reasoning = reasoningParts.join('');
|
|
310
|
+
return reasoning.trim() ? reasoning : undefined;
|
|
128
311
|
}
|
|
129
312
|
|
|
130
313
|
function stripPrivateModeReasoningFromContent(value) {
|
|
@@ -163,6 +346,441 @@ function stripPrivateModeReasoningFromMessages(body) {
|
|
|
163
346
|
});
|
|
164
347
|
}
|
|
165
348
|
|
|
349
|
+
function normalizePrivateModeReasoningPayload(value) {
|
|
350
|
+
if (typeof value === 'string') return value.trim() ? value : undefined;
|
|
351
|
+
if (Array.isArray(value)) {
|
|
352
|
+
const joined = value
|
|
353
|
+
.map(normalizePrivateModeReasoningPayload)
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.join('');
|
|
356
|
+
return joined || undefined;
|
|
357
|
+
}
|
|
358
|
+
if (!isPlainObject(value)) return undefined;
|
|
359
|
+
return normalizePrivateModeReasoningPayload(
|
|
360
|
+
value.text ?? value.content ?? value.reasoning_content ?? value.reasoning ?? value.thinking,
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function normalizePrivateModeKimiK3ReasoningFromMessages(body) {
|
|
365
|
+
if (!Array.isArray(body.messages)) return;
|
|
366
|
+
|
|
367
|
+
body.messages = body.messages.map((message) => {
|
|
368
|
+
if (!isPlainObject(message) || message.role !== 'assistant') return message;
|
|
369
|
+
|
|
370
|
+
const normalized = { ...message };
|
|
371
|
+
const reasoningContent =
|
|
372
|
+
normalizePrivateModeReasoningPayload(message.reasoning_content) ??
|
|
373
|
+
normalizePrivateModeReasoningPayload(message.reasoning) ??
|
|
374
|
+
normalizePrivateModeReasoningPayload(message.thinking) ??
|
|
375
|
+
normalizePrivateModeReasoningPayload(message.reasoning_details) ??
|
|
376
|
+
extractPrivateModeReasoningBlocks(message.content) ??
|
|
377
|
+
extractPrivateModeReasoningBlocks(message.prompt);
|
|
378
|
+
delete normalized.reasoning;
|
|
379
|
+
delete normalized.reasoning_details;
|
|
380
|
+
delete normalized.thinking;
|
|
381
|
+
if (reasoningContent) normalized.reasoning_content = reasoningContent;
|
|
382
|
+
else delete normalized.reasoning_content;
|
|
383
|
+
if ('content' in normalized) {
|
|
384
|
+
normalized.content = stripPrivateModeReasoningFromContent(normalized.content);
|
|
385
|
+
}
|
|
386
|
+
if ('prompt' in normalized) {
|
|
387
|
+
normalized.prompt = stripPrivateModeReasoningFromContent(normalized.prompt);
|
|
388
|
+
}
|
|
389
|
+
return normalized;
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function normalizePrivateModeKimiK3DynamicToolMessages(body) {
|
|
394
|
+
if (!Array.isArray(body.messages)) return;
|
|
395
|
+
|
|
396
|
+
body.messages = body.messages.flatMap((message) => {
|
|
397
|
+
if (!isPlainObject(message) || message.role !== 'system' || !Array.isArray(message.tools)) {
|
|
398
|
+
return [message];
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const { tools, ...messageWithoutTools } = message;
|
|
402
|
+
const hasContent =
|
|
403
|
+
messageWithoutTools.content !== undefined &&
|
|
404
|
+
messageWithoutTools.content !== null &&
|
|
405
|
+
!(typeof messageWithoutTools.content === 'string' && messageWithoutTools.content.trim() === '');
|
|
406
|
+
const normalizedMessages = hasContent ? [messageWithoutTools] : [];
|
|
407
|
+
if (tools.length > 0) normalizedMessages.push({ role: 'system', tools });
|
|
408
|
+
return normalizedMessages;
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function isPrivateModeKimiK3Model(model) {
|
|
413
|
+
return [model.upstreamModel, model.billingModel, ...(model.aliases || [])]
|
|
414
|
+
.some((value) => {
|
|
415
|
+
const normalized = String(value).trim().toLowerCase();
|
|
416
|
+
return normalized === 'kimi-k3' || normalized === 'tee/kimi-k3';
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function getPrivateModeFunctionToolName(tool) {
|
|
421
|
+
if (!isPlainObject(tool) || tool.type !== 'function' || !isPlainObject(tool.function)) {
|
|
422
|
+
return undefined;
|
|
423
|
+
}
|
|
424
|
+
const name = tool.function.name;
|
|
425
|
+
return typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function getPrivateModeNamedToolChoiceName(toolChoice) {
|
|
429
|
+
if (!isPlainObject(toolChoice)) return undefined;
|
|
430
|
+
if (toolChoice.type !== undefined && String(toolChoice.type).toLowerCase() !== 'function') {
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
const name = isPlainObject(toolChoice.function)
|
|
434
|
+
? toolChoice.function.name
|
|
435
|
+
: toolChoice.name;
|
|
436
|
+
return typeof name === 'string' && name.trim() ? name.trim() : undefined;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function normalizePrivateModeKimiK3NamedToolChoice(body) {
|
|
440
|
+
const selectedToolName = getPrivateModeNamedToolChoiceName(body.tool_choice);
|
|
441
|
+
if (!selectedToolName) return;
|
|
442
|
+
|
|
443
|
+
const dynamicTools = Array.isArray(body.messages)
|
|
444
|
+
? body.messages.flatMap((message) => (
|
|
445
|
+
isPlainObject(message) && message.role === 'system' && Array.isArray(message.tools)
|
|
446
|
+
? message.tools
|
|
447
|
+
: []
|
|
448
|
+
))
|
|
449
|
+
: [];
|
|
450
|
+
const selectedTool = [
|
|
451
|
+
...(Array.isArray(body.tools) ? body.tools : []),
|
|
452
|
+
...dynamicTools,
|
|
453
|
+
].find((tool) => getPrivateModeFunctionToolName(tool) === selectedToolName);
|
|
454
|
+
if (!selectedTool) return;
|
|
455
|
+
|
|
456
|
+
body.tools = [selectedTool];
|
|
457
|
+
body.tool_choice = 'required';
|
|
458
|
+
if (dynamicTools.length === 0 || !Array.isArray(body.messages)) return;
|
|
459
|
+
body.messages = body.messages.flatMap((message) => {
|
|
460
|
+
if (!isPlainObject(message) || message.role !== 'system' || !Array.isArray(message.tools)) {
|
|
461
|
+
return [message];
|
|
462
|
+
}
|
|
463
|
+
const { tools: _tools, ...messageWithoutTools } = message;
|
|
464
|
+
const hasContent =
|
|
465
|
+
messageWithoutTools.content !== undefined &&
|
|
466
|
+
messageWithoutTools.content !== null &&
|
|
467
|
+
!(typeof messageWithoutTools.content === 'string' && messageWithoutTools.content.trim() === '');
|
|
468
|
+
return hasContent ? [messageWithoutTools] : [];
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function getKimiK3ReasoningEffortCandidate(value) {
|
|
473
|
+
return isPlainObject(value) ? value.effort : value;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function isKimiK3ReasoningOptOutCandidate(value) {
|
|
477
|
+
if (value === false) return true;
|
|
478
|
+
if (typeof value === 'string') {
|
|
479
|
+
const normalized = value.trim().toLowerCase();
|
|
480
|
+
return normalized === 'none' || normalized === 'off';
|
|
481
|
+
}
|
|
482
|
+
if (!isPlainObject(value)) return false;
|
|
483
|
+
const effort = String(value.effort || '').trim().toLowerCase();
|
|
484
|
+
const type = String(value.type || '').trim().toLowerCase();
|
|
485
|
+
return value.enabled === false ||
|
|
486
|
+
effort === 'none' ||
|
|
487
|
+
effort === 'off' ||
|
|
488
|
+
type === 'disabled' ||
|
|
489
|
+
type === 'off';
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function coercePrivateModeBooleanFlag(value) {
|
|
493
|
+
if (typeof value === 'boolean') return value;
|
|
494
|
+
if (typeof value !== 'string') return undefined;
|
|
495
|
+
const normalized = value.trim().toLowerCase();
|
|
496
|
+
if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
|
|
497
|
+
if (['false', '0', 'no', 'off'].includes(normalized)) return false;
|
|
498
|
+
return undefined;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
export function normalizePrivateModelReasoningControls(body, model) {
|
|
502
|
+
if (!isPrivateModeKimiK3Model(model)) return body;
|
|
503
|
+
const includeReasoning = coercePrivateModeBooleanFlag(body.include_reasoning);
|
|
504
|
+
if (includeReasoning === undefined) return body;
|
|
505
|
+
const reasoning = isPlainObject(body.reasoning) ? { ...body.reasoning } : {};
|
|
506
|
+
if (includeReasoning) reasoning.enabled = true;
|
|
507
|
+
else reasoning.exclude = true;
|
|
508
|
+
body.reasoning = reasoning;
|
|
509
|
+
return body;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export function shouldSuppressPrivateModelReasoning(body, model) {
|
|
513
|
+
if (!isPrivateModeKimiK3Model(model)) return false;
|
|
514
|
+
return body.reasoningOptOut === true ||
|
|
515
|
+
body.exposeReasoning === false ||
|
|
516
|
+
body.materializeReasoning === false ||
|
|
517
|
+
coercePrivateModeBooleanFlag(body.include_reasoning) === false ||
|
|
518
|
+
[body.enable_thinking, body.thinking, body.reasoning_effort, body.reasoning]
|
|
519
|
+
.some((value) => isKimiK3ReasoningOptOutCandidate(value) || (
|
|
520
|
+
isPlainObject(value) && value.exclude === true
|
|
521
|
+
));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function resolvePrivateModeKimiK3ReasoningEffort(body) {
|
|
525
|
+
const candidates = [
|
|
526
|
+
body.reasoningOptOut === true ? false : undefined,
|
|
527
|
+
body.exposeReasoning === false ? false : undefined,
|
|
528
|
+
body.materializeReasoning === false ? false : undefined,
|
|
529
|
+
body.enable_thinking,
|
|
530
|
+
body.thinking,
|
|
531
|
+
body.reasoning_effort,
|
|
532
|
+
body.reasoning,
|
|
533
|
+
];
|
|
534
|
+
if (candidates.some(isKimiK3ReasoningOptOutCandidate)) return 'low';
|
|
535
|
+
|
|
536
|
+
const explicitEffort = candidates
|
|
537
|
+
.map(getKimiK3ReasoningEffortCandidate)
|
|
538
|
+
.find((value) => typeof value === 'string' && KIMI_K3_REASONING_EFFORT_LEVELS.has(value.trim().toLowerCase()));
|
|
539
|
+
const normalizedEffort = typeof explicitEffort === 'string'
|
|
540
|
+
? explicitEffort.trim().toLowerCase()
|
|
541
|
+
: undefined;
|
|
542
|
+
const excludesReasoning = candidates.some((value) => isPlainObject(value) && value.exclude === true);
|
|
543
|
+
return excludesReasoning ? normalizedEffort ?? 'low' : normalizedEffort ?? 'max';
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function applyPrivateModeKimiK3RequestParams(body) {
|
|
547
|
+
const reasoningEffort = resolvePrivateModeKimiK3ReasoningEffort(body);
|
|
548
|
+
const requestedMaxTokens = typeof body.max_tokens === 'number' && Number.isFinite(body.max_tokens)
|
|
549
|
+
? body.max_tokens
|
|
550
|
+
: undefined;
|
|
551
|
+
const normalizedMaxTokens = requestedMaxTokens === undefined
|
|
552
|
+
? reasoningEffort === 'max'
|
|
553
|
+
? KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS
|
|
554
|
+
: KIMI_K3_STANDARD_DEFAULT_MAX_COMPLETION_TOKENS
|
|
555
|
+
: requestedMaxTokens < 0
|
|
556
|
+
? KIMI_K3_MAX_COMPLETION_TOKENS
|
|
557
|
+
: requestedMaxTokens;
|
|
558
|
+
normalizePrivateModeKimiK3NamedToolChoice(body);
|
|
559
|
+
const promptTokenEstimate = estimatePrivateModeKimiK3PromptTokens(body);
|
|
560
|
+
const remainingContext = Math.max(
|
|
561
|
+
1,
|
|
562
|
+
KIMI_K3_CONTEXT_WINDOW_TOKENS -
|
|
563
|
+
promptTokenEstimate -
|
|
564
|
+
KIMI_K3_CONTEXT_SAFETY_MARGIN_TOKENS,
|
|
565
|
+
);
|
|
566
|
+
|
|
567
|
+
delete body.temperature;
|
|
568
|
+
delete body.top_p;
|
|
569
|
+
delete body.n;
|
|
570
|
+
delete body.frequency_penalty;
|
|
571
|
+
delete body.presence_penalty;
|
|
572
|
+
delete body.logit_bias;
|
|
573
|
+
delete body.thinking;
|
|
574
|
+
delete body.enable_thinking;
|
|
575
|
+
delete body.reasoning;
|
|
576
|
+
body.reasoning_effort = reasoningEffort;
|
|
577
|
+
body.max_tokens = Math.min(
|
|
578
|
+
Math.max(1, Math.floor(normalizedMaxTokens)),
|
|
579
|
+
remainingContext,
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
export function createPrivateModeReasoningContentSuppressor() {
|
|
584
|
+
const openTags = ['<think>', '<thinking>', '<previous_reasoning>', '◁think▷'];
|
|
585
|
+
const closeTags = ['</think>', '</thinking>', '</previous_reasoning>', '◁/think▷'];
|
|
586
|
+
const allTags = [...openTags, ...closeTags];
|
|
587
|
+
const maxTagLength = Math.max(...allTags.map((tag) => tag.length));
|
|
588
|
+
let buffered = '';
|
|
589
|
+
let insideReasoning = false;
|
|
590
|
+
|
|
591
|
+
const findEarliestTag = (text, tags) => {
|
|
592
|
+
let earliest = null;
|
|
593
|
+
const normalizedText = text.toLowerCase();
|
|
594
|
+
for (const tag of tags) {
|
|
595
|
+
const index = normalizedText.indexOf(tag.toLowerCase());
|
|
596
|
+
if (index >= 0 && (!earliest || index < earliest.index)) earliest = { index, tag };
|
|
597
|
+
}
|
|
598
|
+
return earliest;
|
|
599
|
+
};
|
|
600
|
+
const findPartialTagIndex = (text, tags) => {
|
|
601
|
+
const start = Math.max(0, text.length - maxTagLength + 1);
|
|
602
|
+
for (let index = start; index < text.length; index += 1) {
|
|
603
|
+
const suffix = text.slice(index).toLowerCase();
|
|
604
|
+
if (tags.some((tag) => {
|
|
605
|
+
const normalizedTag = tag.toLowerCase();
|
|
606
|
+
return normalizedTag.startsWith(suffix) && normalizedTag !== suffix;
|
|
607
|
+
})) return index;
|
|
608
|
+
}
|
|
609
|
+
return -1;
|
|
610
|
+
};
|
|
611
|
+
|
|
612
|
+
return {
|
|
613
|
+
process(value) {
|
|
614
|
+
let input = buffered + String(value || '');
|
|
615
|
+
buffered = '';
|
|
616
|
+
let output = '';
|
|
617
|
+
while (input) {
|
|
618
|
+
const tags = insideReasoning ? closeTags : openTags;
|
|
619
|
+
const match = findEarliestTag(input, tags);
|
|
620
|
+
if (match) {
|
|
621
|
+
if (!insideReasoning) output += input.slice(0, match.index);
|
|
622
|
+
input = input.slice(match.index + match.tag.length);
|
|
623
|
+
insideReasoning = !insideReasoning;
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
const partialIndex = findPartialTagIndex(input, tags);
|
|
627
|
+
if (!insideReasoning) {
|
|
628
|
+
output += partialIndex >= 0 ? input.slice(0, partialIndex) : input;
|
|
629
|
+
}
|
|
630
|
+
if (partialIndex >= 0) buffered = input.slice(partialIndex);
|
|
631
|
+
return output;
|
|
632
|
+
}
|
|
633
|
+
return output;
|
|
634
|
+
},
|
|
635
|
+
flush() {
|
|
636
|
+
const output = insideReasoning ? '' : buffered;
|
|
637
|
+
buffered = '';
|
|
638
|
+
return output;
|
|
639
|
+
},
|
|
640
|
+
};
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export function splitCompleteSseFrames(buffer) {
|
|
644
|
+
const parts = buffer.split(/\r?\n\r?\n/);
|
|
645
|
+
return {
|
|
646
|
+
frames: parts.slice(0, -1).map((frame) => frame.replace(/\r\n/g, '\n')),
|
|
647
|
+
remainder: parts.at(-1) || '',
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
const PRIVATE_MODE_SSE_CHUNK_METADATA_FIELDS = [
|
|
652
|
+
'id',
|
|
653
|
+
'object',
|
|
654
|
+
'created',
|
|
655
|
+
'model',
|
|
656
|
+
'system_fingerprint',
|
|
657
|
+
'service_tier',
|
|
658
|
+
];
|
|
659
|
+
|
|
660
|
+
export function capturePrivateModeSseChunkMetadata(metadata, payload) {
|
|
661
|
+
if (!isPlainObject(metadata) || !isPlainObject(payload)) return metadata;
|
|
662
|
+
for (const field of PRIVATE_MODE_SSE_CHUNK_METADATA_FIELDS) {
|
|
663
|
+
if (payload[field] !== undefined) metadata[field] = payload[field];
|
|
664
|
+
}
|
|
665
|
+
return metadata;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function buildPrivateModeSseContentDelta(content, metadata = {}) {
|
|
669
|
+
return JSON.stringify({
|
|
670
|
+
...metadata,
|
|
671
|
+
choices: [{ index: 0, delta: { content } }],
|
|
672
|
+
});
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export function suppressPrivateModeReasoningFromResponsePayload(
|
|
676
|
+
payload,
|
|
677
|
+
stripContent = stripPrivateModeReasoningBlocks,
|
|
678
|
+
) {
|
|
679
|
+
if (!isPlainObject(payload)) {
|
|
680
|
+
throw new TypeError('Private Mode response payload must be an object.');
|
|
681
|
+
}
|
|
682
|
+
const targets = [
|
|
683
|
+
payload,
|
|
684
|
+
...(Array.isArray(payload.choices) ? payload.choices : []),
|
|
685
|
+
];
|
|
686
|
+
for (const target of targets) {
|
|
687
|
+
if (!isPlainObject(target)) continue;
|
|
688
|
+
const nestedTargets = [target, target.message, target.delta].filter(isPlainObject);
|
|
689
|
+
for (const nested of nestedTargets) {
|
|
690
|
+
for (const field of ASSISTANT_REASONING_MESSAGE_FIELDS) delete nested[field];
|
|
691
|
+
delete nested.thinking;
|
|
692
|
+
for (const field of ['content', 'text', 'output_text']) {
|
|
693
|
+
if (typeof nested[field] === 'string') {
|
|
694
|
+
nested[field] = stripContent(nested[field]);
|
|
695
|
+
} else if (Array.isArray(nested[field])) {
|
|
696
|
+
nested[field] = nested[field].map((part) => (
|
|
697
|
+
isPlainObject(part) && part.type === 'text' && typeof part.text === 'string'
|
|
698
|
+
? { ...part, text: stripContent(part.text) }
|
|
699
|
+
: part
|
|
700
|
+
));
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
return payload;
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function suppressPrivateModeReasoningFromJsonText(value) {
|
|
709
|
+
const parsed = JSON.parse(String(value));
|
|
710
|
+
suppressPrivateModeReasoningFromResponsePayload(parsed);
|
|
711
|
+
return JSON.stringify(parsed);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
export function suppressPrivateModeReasoningFromSseFrame(frame, contentSuppressor, chunkMetadata) {
|
|
715
|
+
const lines = frame.split('\n');
|
|
716
|
+
const dataLineIndexes = lines.flatMap((line, index) => line.startsWith('data:') ? [index] : []);
|
|
717
|
+
if (dataLineIndexes.length === 0) {
|
|
718
|
+
const isCommentOnlyFrame =
|
|
719
|
+
lines.some((line) => line.startsWith(':')) &&
|
|
720
|
+
lines.every((line) => line === '' || line.startsWith(':'));
|
|
721
|
+
if (isCommentOnlyFrame) return frame;
|
|
722
|
+
|
|
723
|
+
// A stream request can still receive an ordinary JSON (or otherwise
|
|
724
|
+
// non-SSE) body. Reasoning opt-outs are a privacy boundary, so never pass
|
|
725
|
+
// a frame through unless its data payload can be parsed and sanitized.
|
|
726
|
+
return '';
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const payload = dataLineIndexes
|
|
730
|
+
.map((index) => lines[index].slice('data:'.length).trim())
|
|
731
|
+
.join('\n')
|
|
732
|
+
.trim();
|
|
733
|
+
if (!payload) return frame;
|
|
734
|
+
|
|
735
|
+
const replaceDataPayload = (replacement) => lines
|
|
736
|
+
.flatMap((line, index) => {
|
|
737
|
+
if (index === dataLineIndexes[0]) return [`data: ${replacement}`];
|
|
738
|
+
return dataLineIndexes.includes(index) ? [] : [line];
|
|
739
|
+
})
|
|
740
|
+
.join('\n');
|
|
741
|
+
|
|
742
|
+
if (payload === '[DONE]') {
|
|
743
|
+
const flushedContent = contentSuppressor.flush();
|
|
744
|
+
if (!flushedContent) return frame;
|
|
745
|
+
const finalDelta = buildPrivateModeSseContentDelta(flushedContent, chunkMetadata);
|
|
746
|
+
return `data: ${finalDelta}\n\n${replaceDataPayload('[DONE]')}`;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
try {
|
|
750
|
+
const parsed = JSON.parse(payload);
|
|
751
|
+
capturePrivateModeSseChunkMetadata(chunkMetadata, parsed);
|
|
752
|
+
suppressPrivateModeReasoningFromResponsePayload(
|
|
753
|
+
parsed,
|
|
754
|
+
(content) => contentSuppressor.process(content),
|
|
755
|
+
);
|
|
756
|
+
const terminalChoice = Array.isArray(parsed.choices) && parsed.choices.find(
|
|
757
|
+
(choice) => choice && choice.finish_reason !== undefined && choice.finish_reason !== null,
|
|
758
|
+
);
|
|
759
|
+
if (!terminalChoice) return replaceDataPayload(JSON.stringify(parsed));
|
|
760
|
+
|
|
761
|
+
const flushedContent = contentSuppressor.flush();
|
|
762
|
+
if (!flushedContent) return replaceDataPayload(JSON.stringify(parsed));
|
|
763
|
+
|
|
764
|
+
let visibleContent = '';
|
|
765
|
+
if (isPlainObject(terminalChoice.delta) && typeof terminalChoice.delta.content === 'string') {
|
|
766
|
+
visibleContent = terminalChoice.delta.content;
|
|
767
|
+
terminalChoice.delta.content = '';
|
|
768
|
+
} else if (typeof terminalChoice.text === 'string') {
|
|
769
|
+
visibleContent = terminalChoice.text;
|
|
770
|
+
terminalChoice.text = '';
|
|
771
|
+
}
|
|
772
|
+
const finalDelta = buildPrivateModeSseContentDelta(
|
|
773
|
+
`${visibleContent}${flushedContent}`,
|
|
774
|
+
chunkMetadata,
|
|
775
|
+
);
|
|
776
|
+
return `data: ${finalDelta}\n\n${replaceDataPayload(JSON.stringify(parsed))}`;
|
|
777
|
+
} catch {
|
|
778
|
+
// Reasoning opt-outs are a privacy boundary. Never pass an unparseable
|
|
779
|
+
// upstream data frame through because it may contain unsanitized reasoning.
|
|
780
|
+
return '';
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
166
784
|
function buildJsonSchemaPromptSuffix(responseFormat) {
|
|
167
785
|
if (!isPlainObject(responseFormat?.json_schema)) return null;
|
|
168
786
|
const schemaWrapper = responseFormat.json_schema;
|
|
@@ -240,12 +858,20 @@ function normalizeStreamOptions(body) {
|
|
|
240
858
|
}
|
|
241
859
|
|
|
242
860
|
export function applyPrivateModelRequestMutations(body, model) {
|
|
243
|
-
|
|
861
|
+
if (isPrivateModeKimiK3Model(model)) {
|
|
862
|
+
normalizePrivateModelReasoningControls(body, model);
|
|
863
|
+
normalizePrivateModeKimiK3ReasoningFromMessages(body);
|
|
864
|
+
normalizePrivateModeKimiK3DynamicToolMessages(body);
|
|
865
|
+
} else {
|
|
866
|
+
stripPrivateModeReasoningFromMessages(body);
|
|
867
|
+
}
|
|
244
868
|
normalizeMaxTokenAliases(body);
|
|
245
|
-
|
|
246
869
|
body.model = model.upstreamModel;
|
|
247
870
|
normalizeStreamOptions(body);
|
|
248
871
|
applyTinfoilCompatibilityMutations(body, model);
|
|
872
|
+
if (isPrivateModeKimiK3Model(model)) {
|
|
873
|
+
applyPrivateModeKimiK3RequestParams(body);
|
|
874
|
+
}
|
|
249
875
|
|
|
250
876
|
if (model.thinkingMode === 'gemma') {
|
|
251
877
|
body.chat_template_kwargs = {
|
|
@@ -254,7 +880,7 @@ export function applyPrivateModelRequestMutations(body, model) {
|
|
|
254
880
|
};
|
|
255
881
|
delete body.thinking;
|
|
256
882
|
delete body.reasoning_effort;
|
|
257
|
-
} else if (model.thinkingMode === '
|
|
883
|
+
} else if (model.thinkingMode === 'glm-5.2') {
|
|
258
884
|
const thinkingEnabled = shouldEnableThinking(body, model);
|
|
259
885
|
body.chat_template_kwargs = {
|
|
260
886
|
...mergeChatTemplateKwargs(body),
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
const DEFAULT_SECURE_CLIENT_IDLE_RESET_MS = 5 * 60 * 1000;
|
|
2
|
+
const EHBP_RESPONSE_NONCE_HEADER = 'ehbp-response-nonce';
|
|
2
3
|
|
|
3
4
|
function readSecureClientIdleResetMs() {
|
|
4
|
-
const
|
|
5
|
-
|
|
5
|
+
const configured = process.env.NANOGPT_PRIVATE_CLIENT_IDLE_RESET_MS?.trim();
|
|
6
|
+
const parsed = configured ? Number(configured) : Number.NaN;
|
|
7
|
+
if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed);
|
|
6
8
|
return DEFAULT_SECURE_CLIENT_IDLE_RESET_MS;
|
|
7
9
|
}
|
|
8
10
|
|
|
9
11
|
export function createSecureState(apiBase, options = {}) {
|
|
10
|
-
const idleResetMs = Number.isFinite(options.idleResetMs) && options.idleResetMs
|
|
12
|
+
const idleResetMs = Number.isFinite(options.idleResetMs) && options.idleResetMs >= 0
|
|
11
13
|
? Math.floor(options.idleResetMs)
|
|
12
14
|
: readSecureClientIdleResetMs();
|
|
13
15
|
const loadTinfoil = options.loadTinfoil || (() => import('tinfoil'));
|
|
@@ -24,8 +26,10 @@ export function createSecureState(apiBase, options = {}) {
|
|
|
24
26
|
const idleMs = clientState ? nowMs - clientState.lastUsedAtMs : 0;
|
|
25
27
|
if (
|
|
26
28
|
clientState?.userCacheSecret === userCacheSecret
|
|
27
|
-
&&
|
|
28
|
-
|
|
29
|
+
&& (
|
|
30
|
+
idleResetMs === 0
|
|
31
|
+
|| (idleMs >= 0 && idleMs < idleResetMs)
|
|
32
|
+
)
|
|
29
33
|
) {
|
|
30
34
|
clientState.lastUsedAtMs = nowMs;
|
|
31
35
|
return clientState.promise;
|
|
@@ -74,6 +78,11 @@ export function createSecureState(apiBase, options = {}) {
|
|
|
74
78
|
|
|
75
79
|
return {
|
|
76
80
|
getClient,
|
|
81
|
+
markClientUsed(client) {
|
|
82
|
+
if (!clientState || clientState.client !== client) return false;
|
|
83
|
+
clientState.lastUsedAtMs = now();
|
|
84
|
+
return true;
|
|
85
|
+
},
|
|
77
86
|
invalidateClient(client) {
|
|
78
87
|
if (!clientState) return false;
|
|
79
88
|
if (client && clientState.client !== client) return false;
|
|
@@ -94,6 +103,10 @@ export function createSecureState(apiBase, options = {}) {
|
|
|
94
103
|
|
|
95
104
|
export async function isMissingEncryptedBodyHeaderResponse(response) {
|
|
96
105
|
if (response?.status !== 400) return false;
|
|
106
|
+
// Only NanoGPT's plaintext pre-dispatch rejection is safe to retry. An EHBP
|
|
107
|
+
// nonce means the response came back through the encrypted provider path, so
|
|
108
|
+
// the original request may already have been processed.
|
|
109
|
+
if (response.headers.has(EHBP_RESPONSE_NONCE_HEADER)) return false;
|
|
97
110
|
try {
|
|
98
111
|
const data = await response.clone().json();
|
|
99
112
|
return data?.error?.code === 'missing_encrypted_body_header';
|
|
@@ -104,6 +117,7 @@ export async function isMissingEncryptedBodyHeaderResponse(response) {
|
|
|
104
117
|
|
|
105
118
|
export async function fetchWithSecureClientRecovery({
|
|
106
119
|
fetchWithClient,
|
|
120
|
+
onResponseClient,
|
|
107
121
|
secureState,
|
|
108
122
|
shouldInvalidateError = () => true,
|
|
109
123
|
userCacheSecret,
|
|
@@ -118,6 +132,7 @@ export async function fetchWithSecureClientRecovery({
|
|
|
118
132
|
secureState.invalidateClient(client);
|
|
119
133
|
continue;
|
|
120
134
|
}
|
|
135
|
+
onResponseClient?.(client);
|
|
121
136
|
return response;
|
|
122
137
|
}
|
|
123
138
|
} catch (error) {
|
package/lib/server.js
CHANGED
|
@@ -6,7 +6,15 @@ import {
|
|
|
6
6
|
buildPrivateModeOriginPolicy,
|
|
7
7
|
getCorsHeadersForRequest,
|
|
8
8
|
} from './originPolicy.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
applyPrivateModelRequestMutations,
|
|
11
|
+
buildPrivateModeSseContentDelta,
|
|
12
|
+
createPrivateModeReasoningContentSuppressor,
|
|
13
|
+
shouldSuppressPrivateModelReasoning,
|
|
14
|
+
splitCompleteSseFrames,
|
|
15
|
+
suppressPrivateModeReasoningFromSseFrame,
|
|
16
|
+
suppressPrivateModeReasoningFromJsonText,
|
|
17
|
+
} from './requestTransforms.js';
|
|
10
18
|
import {
|
|
11
19
|
normalizePrivateModeUpstreamErrorMessage,
|
|
12
20
|
readErrorMessage,
|
|
@@ -68,6 +76,7 @@ function openAIModelList() {
|
|
|
68
76
|
object: 'model',
|
|
69
77
|
created: model.created,
|
|
70
78
|
owned_by: model.ownedBy,
|
|
79
|
+
...(model.maxOutputTokens ? { max_output_tokens: model.maxOutputTokens } : {}),
|
|
71
80
|
})),
|
|
72
81
|
};
|
|
73
82
|
}
|
|
@@ -264,6 +273,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
264
273
|
return;
|
|
265
274
|
}
|
|
266
275
|
|
|
276
|
+
const suppressReasoning = shouldSuppressPrivateModelReasoning(body, model);
|
|
267
277
|
applyPrivateModelRequestMutations(body, model);
|
|
268
278
|
const privateStreamRequested = body.stream === true;
|
|
269
279
|
|
|
@@ -281,6 +291,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
281
291
|
}
|
|
282
292
|
|
|
283
293
|
let response;
|
|
294
|
+
let responseClient;
|
|
284
295
|
const upstreamAbortController = new AbortController();
|
|
285
296
|
let responseComplete = false;
|
|
286
297
|
res.on('close', () => {
|
|
@@ -307,6 +318,9 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
307
318
|
body: privateRequestBody,
|
|
308
319
|
signal: upstreamAbortController.signal,
|
|
309
320
|
}),
|
|
321
|
+
onResponseClient: (client) => {
|
|
322
|
+
responseClient = client;
|
|
323
|
+
},
|
|
310
324
|
});
|
|
311
325
|
} catch (error) {
|
|
312
326
|
responseComplete = true;
|
|
@@ -342,12 +356,19 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
342
356
|
code: 'private_mode_upstream_failed',
|
|
343
357
|
},
|
|
344
358
|
}, errorHeaders);
|
|
359
|
+
secureState.markClientUsed(responseClient);
|
|
345
360
|
return;
|
|
346
361
|
}
|
|
347
362
|
|
|
348
363
|
if (privateStreamRequested && response.body) {
|
|
349
364
|
res.writeHead(response.status, headers);
|
|
350
365
|
const reader = response.body.getReader();
|
|
366
|
+
const decoder = suppressReasoning ? new TextDecoder() : null;
|
|
367
|
+
const contentSuppressor = suppressReasoning
|
|
368
|
+
? createPrivateModeReasoningContentSuppressor()
|
|
369
|
+
: null;
|
|
370
|
+
const sseChunkMetadata = {};
|
|
371
|
+
let sseBuffer = '';
|
|
351
372
|
let streamFailed = false;
|
|
352
373
|
try {
|
|
353
374
|
while (true) {
|
|
@@ -358,7 +379,28 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
358
379
|
upstreamAbortController.abort();
|
|
359
380
|
return;
|
|
360
381
|
}
|
|
361
|
-
if (value)
|
|
382
|
+
if (!value) continue;
|
|
383
|
+
if (!suppressReasoning || !decoder || !contentSuppressor) {
|
|
384
|
+
res.write(Buffer.from(value));
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
sseBuffer += decoder.decode(value, { stream: true });
|
|
388
|
+
const { frames, remainder } = splitCompleteSseFrames(sseBuffer);
|
|
389
|
+
sseBuffer = remainder;
|
|
390
|
+
for (const frame of frames) {
|
|
391
|
+
res.write(`${suppressPrivateModeReasoningFromSseFrame(frame, contentSuppressor, sseChunkMetadata)}\n\n`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (suppressReasoning && decoder && contentSuppressor) {
|
|
395
|
+
sseBuffer += decoder.decode();
|
|
396
|
+
if (sseBuffer) {
|
|
397
|
+
res.write(`${suppressPrivateModeReasoningFromSseFrame(sseBuffer, contentSuppressor, sseChunkMetadata)}\n\n`);
|
|
398
|
+
}
|
|
399
|
+
const flushedContent = contentSuppressor.flush();
|
|
400
|
+
if (flushedContent) {
|
|
401
|
+
const finalDelta = buildPrivateModeSseContentDelta(flushedContent, sseChunkMetadata);
|
|
402
|
+
res.write(`data: ${finalDelta}\n\n`);
|
|
403
|
+
}
|
|
362
404
|
}
|
|
363
405
|
} catch (error) {
|
|
364
406
|
streamFailed = true;
|
|
@@ -369,12 +411,32 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
369
411
|
return;
|
|
370
412
|
} finally {
|
|
371
413
|
responseComplete = true;
|
|
414
|
+
if (!streamFailed) secureState.markClientUsed(responseClient);
|
|
372
415
|
if (!streamFailed && !res.writableEnded && !res.destroyed) res.end();
|
|
373
416
|
}
|
|
374
417
|
return;
|
|
375
418
|
}
|
|
376
419
|
|
|
377
|
-
|
|
420
|
+
let responseBody = Buffer.from(await response.arrayBuffer());
|
|
421
|
+
if (suppressReasoning) {
|
|
422
|
+
try {
|
|
423
|
+
responseBody = Buffer.from(
|
|
424
|
+
suppressPrivateModeReasoningFromJsonText(responseBody.toString('utf8')),
|
|
425
|
+
);
|
|
426
|
+
} catch {
|
|
427
|
+
secureState.markClientUsed(responseClient);
|
|
428
|
+
responseComplete = true;
|
|
429
|
+
jsonResponse(res, 502, {
|
|
430
|
+
error: {
|
|
431
|
+
message: 'Private Mode upstream returned an invalid response.',
|
|
432
|
+
type: 'api_error',
|
|
433
|
+
code: 'private_mode_invalid_response',
|
|
434
|
+
},
|
|
435
|
+
}, corsHeaders);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
secureState.markClientUsed(responseClient);
|
|
378
440
|
headers['content-length'] = String(responseBody.length);
|
|
379
441
|
responseComplete = true;
|
|
380
442
|
res.writeHead(response.status, headers);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
// Keep user-facing upstream error classification in sync with
|
|
2
|
-
// lib/privateMode/tinfoilBrowserClient.ts.
|
|
3
|
-
//
|
|
2
|
+
// lib/privateMode/tinfoilBrowserClient.ts. Only surface sanitized client-error
|
|
3
|
+
// messages from responses that completed the verified Private Mode transport.
|
|
4
4
|
const MISSING_EHBP_RESPONSE_NONCE_PATTERN = /missing\s+ehbp-response-nonce\s+header/i;
|
|
5
|
+
const EHBP_RESPONSE_NONCE_HEADER = 'ehbp-response-nonce';
|
|
5
6
|
const PRIVATE_MODE_REFUND_NOTICE = 'Any reserved balance will be released or refunded.';
|
|
7
|
+
const MAX_UPSTREAM_ERROR_BODY_BYTES = 16 * 1024;
|
|
8
|
+
const MAX_UPSTREAM_ERROR_MESSAGE_CHARS = 1_000;
|
|
9
|
+
const HIDDEN_PROVIDER_NAME_PATTERN = /\b(?:openrouter|spoke\s*ai|aihubmix|ollama|mimas|comet|azure|digital\s*ocean|axionic|whale\s*ai|langfork)\b/gi;
|
|
6
10
|
|
|
7
11
|
export function privateModeProviderFailureMessage(status) {
|
|
8
12
|
if (status === 429) {
|
|
@@ -19,12 +23,109 @@ export function normalizePrivateModeUpstreamErrorMessage(message, fallback) {
|
|
|
19
23
|
return privateModeProviderFailureMessage();
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
|
|
23
|
-
|
|
26
|
+
function sanitizeUpstreamErrorMessage(value) {
|
|
27
|
+
if (typeof value !== 'string') return null;
|
|
28
|
+
const normalized = value
|
|
29
|
+
.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, ' ')
|
|
30
|
+
.replace(/\s+/g, ' ')
|
|
31
|
+
.replace(HIDDEN_PROVIDER_NAME_PATTERN, 'upstream service')
|
|
32
|
+
.trim();
|
|
33
|
+
if (!normalized) return null;
|
|
34
|
+
const characters = Array.from(normalized);
|
|
35
|
+
if (characters.length <= MAX_UPSTREAM_ERROR_MESSAGE_CHARS) return normalized;
|
|
36
|
+
return `${characters.slice(0, MAX_UPSTREAM_ERROR_MESSAGE_CHARS - 1).join('').trimEnd()}…`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function discardResponseBody(response) {
|
|
24
40
|
try {
|
|
25
41
|
await response?.body?.cancel();
|
|
26
42
|
} catch {
|
|
27
|
-
// The body may already be locked or closed.
|
|
43
|
+
// The body may already be locked or closed.
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readLimitedResponseText(response) {
|
|
48
|
+
const contentLength = Number(response?.headers?.get?.('content-length') || '');
|
|
49
|
+
if (Number.isFinite(contentLength) && contentLength > MAX_UPSTREAM_ERROR_BODY_BYTES) {
|
|
50
|
+
await discardResponseBody(response);
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const reader = response?.body?.getReader?.();
|
|
55
|
+
if (!reader) return null;
|
|
56
|
+
const decoder = new TextDecoder();
|
|
57
|
+
let bytesRead = 0;
|
|
58
|
+
let text = '';
|
|
59
|
+
try {
|
|
60
|
+
while (true) {
|
|
61
|
+
const { done, value } = await reader.read();
|
|
62
|
+
if (done) break;
|
|
63
|
+
bytesRead += value?.byteLength || 0;
|
|
64
|
+
if (bytesRead > MAX_UPSTREAM_ERROR_BODY_BYTES) {
|
|
65
|
+
await reader.cancel().catch(() => undefined);
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
if (value) text += decoder.decode(value, { stream: true });
|
|
69
|
+
}
|
|
70
|
+
text += decoder.decode();
|
|
71
|
+
return text;
|
|
72
|
+
} catch {
|
|
73
|
+
await reader.cancel().catch(() => undefined);
|
|
74
|
+
return null;
|
|
75
|
+
} finally {
|
|
76
|
+
reader.releaseLock();
|
|
28
77
|
}
|
|
29
|
-
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function extractStructuredErrorMessage(data) {
|
|
81
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) return null;
|
|
82
|
+
const nestedError = data.error && typeof data.error === 'object' && !Array.isArray(data.error)
|
|
83
|
+
? data.error
|
|
84
|
+
: null;
|
|
85
|
+
const candidates = [
|
|
86
|
+
nestedError?.message,
|
|
87
|
+
typeof data.error === 'string' ? data.error : null,
|
|
88
|
+
data.message,
|
|
89
|
+
data.detail,
|
|
90
|
+
];
|
|
91
|
+
for (const candidate of candidates) {
|
|
92
|
+
const sanitized = sanitizeUpstreamErrorMessage(candidate);
|
|
93
|
+
if (sanitized) return sanitized;
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function readErrorMessage(response, fallback) {
|
|
99
|
+
void fallback;
|
|
100
|
+
const status = Number(response?.status);
|
|
101
|
+
const contentType = String(response?.headers?.get?.('content-type') || '').toLowerCase();
|
|
102
|
+
const mediaType = contentType.split(';', 1)[0]?.trim() || '';
|
|
103
|
+
const isJsonMediaType = mediaType === 'application/json' || mediaType.endsWith('+json');
|
|
104
|
+
const mayExposeClientError = (
|
|
105
|
+
response?.headers?.get?.('x-nanogpt-private-mode') === 'tinfoil'
|
|
106
|
+
&& response?.headers?.has?.(EHBP_RESPONSE_NONCE_HEADER)
|
|
107
|
+
&& (status === 400 || status === 422)
|
|
108
|
+
&& (isJsonMediaType || mediaType === 'text/plain')
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
if (!mayExposeClientError) {
|
|
112
|
+
await discardResponseBody(response);
|
|
113
|
+
return privateModeProviderFailureMessage(status);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const body = await readLimitedResponseText(response);
|
|
117
|
+
let message = null;
|
|
118
|
+
if (body !== null && isJsonMediaType) {
|
|
119
|
+
try {
|
|
120
|
+
message = extractStructuredErrorMessage(JSON.parse(body));
|
|
121
|
+
} catch {
|
|
122
|
+
message = null;
|
|
123
|
+
}
|
|
124
|
+
} else if (body !== null) {
|
|
125
|
+
message = sanitizeUpstreamErrorMessage(body);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return message
|
|
129
|
+
? `Private Mode request was rejected: ${message} — ${PRIVATE_MODE_REFUND_NOTICE}`
|
|
130
|
+
: privateModeProviderFailureMessage(status);
|
|
30
131
|
}
|
package/models/private-tee.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
[
|
|
2
2
|
{
|
|
3
|
-
"id": "private/kimi-
|
|
4
|
-
"name": "Kimi
|
|
5
|
-
"upstreamModel": "kimi-
|
|
6
|
-
"billingModel": "TEE/kimi-
|
|
7
|
-
"teeTargetModel": "kimi-
|
|
8
|
-
"
|
|
9
|
-
"created":
|
|
3
|
+
"id": "private/kimi-k3",
|
|
4
|
+
"name": "Kimi K3 Private",
|
|
5
|
+
"upstreamModel": "kimi-k3",
|
|
6
|
+
"billingModel": "TEE/kimi-k3",
|
|
7
|
+
"teeTargetModel": "kimi-k3",
|
|
8
|
+
"maxOutputTokens": 1048576,
|
|
9
|
+
"created": 1786147200,
|
|
10
10
|
"ownedBy": "nanogpt-private-mode",
|
|
11
|
-
"aliases": ["
|
|
11
|
+
"aliases": ["TEE/kimi-k3"]
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
"id": "private/gpt-oss-120b",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanogpt/private-mode",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.5",
|
|
4
4
|
"description": "OpenAI-compatible localhost proxy for NanoGPT Private Mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -23,6 +23,9 @@
|
|
|
23
23
|
"models",
|
|
24
24
|
"README.md"
|
|
25
25
|
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"start": "node ./bin/nanogpt-private-mode.js"
|
|
28
|
+
},
|
|
26
29
|
"dependencies": {
|
|
27
30
|
"ai": "6.0.220",
|
|
28
31
|
"tinfoil": "1.1.12"
|
|
@@ -30,8 +33,5 @@
|
|
|
30
33
|
"engines": {
|
|
31
34
|
"node": ">=22"
|
|
32
35
|
},
|
|
33
|
-
"license": "MIT"
|
|
34
|
-
|
|
35
|
-
"start": "node ./bin/nanogpt-private-mode.js"
|
|
36
|
-
}
|
|
37
|
-
}
|
|
36
|
+
"license": "MIT"
|
|
37
|
+
}
|