@nanogpt/private-mode 0.2.2 → 0.2.6

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 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/kimi-k2-6",
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,8 @@ 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-k2-6`
57
+ - `private/deepseek-v4-flash`
58
+ - `private/kimi-k3`
58
59
  - `private/glm-5-1`
59
60
  - `private/glm-5-1-thinking`
60
61
  - `private/glm-5-2`
@@ -8,9 +8,28 @@ 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();
30
+ const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS = 1_048_576;
31
+ const DEEPSEEK_V4_MAX_COMPLETION_TOKENS = 1_048_576;
32
+ const DEEPSEEK_V4_CONTEXT_SAFETY_MARGIN_TOKENS = 10;
14
33
  const PRIVATE_TINFOIL_CHAT_COMPLETION_BODY_FIELDS = new Set([
15
34
  'chat_template_kwargs',
16
35
  'frequency_penalty',
@@ -44,6 +63,149 @@ function isPlainObject(value) {
44
63
  return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
45
64
  }
46
65
 
66
+ function estimateKimiK3TextTokens(value) {
67
+ if (typeof value !== 'string' || value.length === 0) return 0;
68
+ const utf8Bytes = KIMI_K3_TEXT_ENCODER.encode(value).byteLength;
69
+ const ordinaryEstimate = Math.ceil(utf8Bytes / KIMI_K3_ESTIMATED_TEXT_BYTES_PER_TOKEN);
70
+ if (value.length < KIMI_K3_HIGH_ENTROPY_TEXT_MIN_LENGTH) return ordinaryEstimate;
71
+
72
+ let ascii = 0;
73
+ let whitespace = 0;
74
+ const uniqueChars = new Set();
75
+ let currentRun = 0;
76
+ let longestRun = 0;
77
+ let previous = '';
78
+
79
+ for (const char of value) {
80
+ const code = char.charCodeAt(0);
81
+ if (code >= 32 && code <= 126) ascii += 1;
82
+ if (/\s/.test(char)) whitespace += 1;
83
+ uniqueChars.add(char);
84
+ if (char === previous) {
85
+ currentRun += 1;
86
+ } else {
87
+ previous = char;
88
+ currentRun = 1;
89
+ }
90
+ if (currentRun > longestRun) longestRun = currentRun;
91
+ }
92
+
93
+ const isHighEntropy =
94
+ ascii / value.length >= 0.98 &&
95
+ whitespace / value.length <= 0.02 &&
96
+ uniqueChars.size >= 32 &&
97
+ longestRun / value.length <= 0.02;
98
+ if (!isHighEntropy) return ordinaryEstimate;
99
+
100
+ return Math.max(
101
+ ordinaryEstimate,
102
+ Math.ceil(utf8Bytes * KIMI_K3_HIGH_ENTROPY_TOKENS_PER_BYTE),
103
+ );
104
+ }
105
+
106
+ function estimateKimiK3ContentTokens(content) {
107
+ if (typeof content === 'string') return estimateKimiK3TextTokens(content);
108
+ if (Array.isArray(content)) {
109
+ return content.reduce((total, part) => total + estimateKimiK3ContentTokens(part), 0);
110
+ }
111
+ if (!isPlainObject(content)) return 0;
112
+
113
+ const type = String(content.type || '').toLowerCase();
114
+ const nestedFile = isPlainObject(content.file) ? content.file : {};
115
+ const nestedSource = isPlainObject(content.source) ? content.source : {};
116
+ const nestedDocument = isPlainObject(content.document) ? content.document : {};
117
+ const mediaType = String(
118
+ content.media_type ||
119
+ content.mime_type ||
120
+ content.file_type ||
121
+ nestedFile.media_type ||
122
+ nestedFile.mime_type ||
123
+ nestedFile.file_type ||
124
+ nestedSource.media_type ||
125
+ nestedSource.mime_type ||
126
+ nestedDocument.media_type ||
127
+ nestedDocument.mime_type ||
128
+ '',
129
+ ).trim().toLowerCase();
130
+ const hasVideoMediaValue = [
131
+ content.url,
132
+ content.file_url,
133
+ content.file_data,
134
+ nestedFile.url,
135
+ nestedFile.file_url,
136
+ nestedFile.file_data,
137
+ nestedFile.data,
138
+ nestedSource.url,
139
+ nestedSource.data,
140
+ nestedDocument.url,
141
+ nestedDocument.file_url,
142
+ ].some((value) => {
143
+ if (isPlainObject(value)) value = value.url;
144
+ if (typeof value !== 'string') return false;
145
+ const normalized = value.trim().toLowerCase();
146
+ if (normalized.startsWith('data:video/')) return true;
147
+ let pathname = normalized.split(/[?#]/, 1)[0] || normalized;
148
+ try {
149
+ pathname = new URL(value).pathname.toLowerCase();
150
+ } catch {}
151
+ return /\.(?:mp4|mov|mkv|avi|m4v|mpeg|mpg|webm|ogv)$/.test(pathname);
152
+ });
153
+ if (type === 'audio_url' || (!type && content.audio_url)) return KIMI_K3_AUDIO_URL_TOKENS;
154
+ if (type === 'input_audio' || type === 'audio') return KIMI_K3_AUDIO_TOKENS;
155
+ if (
156
+ type === 'video' ||
157
+ type === 'video_url' ||
158
+ type === 'input_video' ||
159
+ mediaType.startsWith('video/') ||
160
+ hasVideoMediaValue ||
161
+ 'video_url' in content ||
162
+ 'input_video' in content
163
+ ) return KIMI_K3_VIDEO_TOKENS;
164
+ if (
165
+ type === 'image' ||
166
+ type === 'image_url' ||
167
+ type === 'input_image' ||
168
+ 'image_url' in content
169
+ ) return KIMI_K3_IMAGE_TOKENS;
170
+ if (
171
+ type === 'document' ||
172
+ type === 'input_file' ||
173
+ type === 'input_document' ||
174
+ 'file_url' in content
175
+ ) return KIMI_K3_DOCUMENT_TOKENS;
176
+
177
+ for (const key of ['text', 'content', 'message']) {
178
+ if (typeof content[key] === 'string') return estimateKimiK3TextTokens(content[key]);
179
+ }
180
+ return estimateKimiK3TextTokens(JSON.stringify(content));
181
+ }
182
+
183
+ function estimatePrivateModeKimiK3PromptTokens(body) {
184
+ let tokens = 3;
185
+ for (const message of Array.isArray(body.messages) ? body.messages : []) {
186
+ if (!isPlainObject(message)) continue;
187
+ tokens += 3;
188
+ tokens += estimateKimiK3TextTokens(message.role);
189
+ tokens += estimateKimiK3TextTokens(message.name);
190
+ tokens += estimateKimiK3ContentTokens(message.content);
191
+ tokens += estimateKimiK3ContentTokens(message.prompt);
192
+ tokens += estimateKimiK3TextTokens(message.reasoning_content);
193
+ if (Array.isArray(message.tools)) {
194
+ tokens += estimateKimiK3TextTokens(JSON.stringify(message.tools));
195
+ }
196
+ if (message.function_call !== undefined) {
197
+ tokens += estimateKimiK3TextTokens(JSON.stringify(message.function_call));
198
+ }
199
+ if (message.tool_calls !== undefined) {
200
+ tokens += estimateKimiK3TextTokens(JSON.stringify(message.tool_calls));
201
+ }
202
+ }
203
+ if (Array.isArray(body.tools)) {
204
+ tokens += estimateKimiK3TextTokens(JSON.stringify(body.tools));
205
+ }
206
+ return tokens;
207
+ }
208
+
47
209
  function isThinkingEnabled(thinking) {
48
210
  if (typeof thinking === 'boolean') return thinking;
49
211
  if (!isPlainObject(thinking)) return undefined;
@@ -123,8 +285,32 @@ function stripPrivateModeReasoningBlocks(value) {
123
285
  .replace(/◁think▷/g, '<think>')
124
286
  .replace(/◁\/think▷/g, '</think>')
125
287
  .replace(/<think>[\s\S]*?<\/think>\s*/gi, '')
288
+ .replace(/<thinking>[\s\S]*?<\/thinking>\s*/gi, '')
126
289
  .replace(/<previous_reasoning>[\s\S]*?<\/previous_reasoning>\s*/gi, '')
127
- .trimStart();
290
+ .replace(/<(?:think|thinking|previous_reasoning)>[\s\S]*$/i, '');
291
+ }
292
+
293
+ function extractPrivateModeReasoningBlocks(value) {
294
+ const textValues = typeof value === 'string'
295
+ ? [value]
296
+ : Array.isArray(value)
297
+ ? value.flatMap((part) => (
298
+ isPlainObject(part) && part.type === 'text' && typeof part.text === 'string'
299
+ ? [part.text]
300
+ : []
301
+ ))
302
+ : [];
303
+ const reasoningParts = textValues.flatMap((text) => {
304
+ const normalized = text
305
+ .replace(/◁think▷/g, '<think>')
306
+ .replace(/◁\/think▷/g, '</think>');
307
+ return Array.from(
308
+ normalized.matchAll(/<(think|thinking|previous_reasoning)>([\s\S]*?)<\/\1>/gi),
309
+ (match) => match[2],
310
+ );
311
+ });
312
+ const reasoning = reasoningParts.join('');
313
+ return reasoning.trim() ? reasoning : undefined;
128
314
  }
129
315
 
130
316
  function stripPrivateModeReasoningFromContent(value) {
@@ -163,6 +349,467 @@ function stripPrivateModeReasoningFromMessages(body) {
163
349
  });
164
350
  }
165
351
 
352
+ function normalizePrivateModeReasoningPayload(value) {
353
+ if (typeof value === 'string') return value.trim() ? value : undefined;
354
+ if (Array.isArray(value)) {
355
+ const joined = value
356
+ .map(normalizePrivateModeReasoningPayload)
357
+ .filter(Boolean)
358
+ .join('');
359
+ return joined || undefined;
360
+ }
361
+ if (!isPlainObject(value)) return undefined;
362
+ return normalizePrivateModeReasoningPayload(
363
+ value.text ?? value.content ?? value.reasoning_content ?? value.reasoning ?? value.thinking,
364
+ );
365
+ }
366
+
367
+ function normalizePrivateModeKimiK3ReasoningFromMessages(body) {
368
+ if (!Array.isArray(body.messages)) return;
369
+
370
+ body.messages = body.messages.map((message) => {
371
+ if (!isPlainObject(message) || message.role !== 'assistant') return message;
372
+
373
+ const normalized = { ...message };
374
+ const reasoningContent =
375
+ normalizePrivateModeReasoningPayload(message.reasoning_content) ??
376
+ normalizePrivateModeReasoningPayload(message.reasoning) ??
377
+ normalizePrivateModeReasoningPayload(message.thinking) ??
378
+ normalizePrivateModeReasoningPayload(message.reasoning_details) ??
379
+ extractPrivateModeReasoningBlocks(message.content) ??
380
+ extractPrivateModeReasoningBlocks(message.prompt);
381
+ delete normalized.reasoning;
382
+ delete normalized.reasoning_details;
383
+ delete normalized.thinking;
384
+ if (reasoningContent) normalized.reasoning_content = reasoningContent;
385
+ else delete normalized.reasoning_content;
386
+ if ('content' in normalized) {
387
+ normalized.content = stripPrivateModeReasoningFromContent(normalized.content);
388
+ }
389
+ if ('prompt' in normalized) {
390
+ normalized.prompt = stripPrivateModeReasoningFromContent(normalized.prompt);
391
+ }
392
+ return normalized;
393
+ });
394
+ }
395
+
396
+ function normalizePrivateModeKimiK3DynamicToolMessages(body) {
397
+ if (!Array.isArray(body.messages)) return;
398
+
399
+ body.messages = body.messages.flatMap((message) => {
400
+ if (!isPlainObject(message) || message.role !== 'system' || !Array.isArray(message.tools)) {
401
+ return [message];
402
+ }
403
+
404
+ const { tools, ...messageWithoutTools } = message;
405
+ const hasContent =
406
+ messageWithoutTools.content !== undefined &&
407
+ messageWithoutTools.content !== null &&
408
+ !(typeof messageWithoutTools.content === 'string' && messageWithoutTools.content.trim() === '');
409
+ const normalizedMessages = hasContent ? [messageWithoutTools] : [];
410
+ if (tools.length > 0) normalizedMessages.push({ role: 'system', tools });
411
+ return normalizedMessages;
412
+ });
413
+ }
414
+
415
+ function isPrivateModeKimiK3Model(model) {
416
+ return [model.upstreamModel, model.billingModel, ...(model.aliases || [])]
417
+ .some((value) => {
418
+ const normalized = String(value).trim().toLowerCase();
419
+ return normalized === 'kimi-k3' || normalized === 'tee/kimi-k3';
420
+ });
421
+ }
422
+
423
+ function getPrivateModeFunctionToolName(tool) {
424
+ if (!isPlainObject(tool) || tool.type !== 'function' || !isPlainObject(tool.function)) {
425
+ return undefined;
426
+ }
427
+ const name = tool.function.name;
428
+ return typeof name === 'string' && name.trim() ? name.trim() : undefined;
429
+ }
430
+
431
+ function getPrivateModeNamedToolChoiceName(toolChoice) {
432
+ if (!isPlainObject(toolChoice)) return undefined;
433
+ if (toolChoice.type !== undefined && String(toolChoice.type).toLowerCase() !== 'function') {
434
+ return undefined;
435
+ }
436
+ const name = isPlainObject(toolChoice.function)
437
+ ? toolChoice.function.name
438
+ : toolChoice.name;
439
+ return typeof name === 'string' && name.trim() ? name.trim() : undefined;
440
+ }
441
+
442
+ function normalizePrivateModeKimiK3NamedToolChoice(body) {
443
+ const selectedToolName = getPrivateModeNamedToolChoiceName(body.tool_choice);
444
+ if (!selectedToolName) return;
445
+
446
+ const dynamicTools = Array.isArray(body.messages)
447
+ ? body.messages.flatMap((message) => (
448
+ isPlainObject(message) && message.role === 'system' && Array.isArray(message.tools)
449
+ ? message.tools
450
+ : []
451
+ ))
452
+ : [];
453
+ const selectedTool = [
454
+ ...(Array.isArray(body.tools) ? body.tools : []),
455
+ ...dynamicTools,
456
+ ].find((tool) => getPrivateModeFunctionToolName(tool) === selectedToolName);
457
+ if (!selectedTool) return;
458
+
459
+ body.tools = [selectedTool];
460
+ body.tool_choice = 'required';
461
+ if (dynamicTools.length === 0 || !Array.isArray(body.messages)) return;
462
+ body.messages = body.messages.flatMap((message) => {
463
+ if (!isPlainObject(message) || message.role !== 'system' || !Array.isArray(message.tools)) {
464
+ return [message];
465
+ }
466
+ const { tools: _tools, ...messageWithoutTools } = message;
467
+ const hasContent =
468
+ messageWithoutTools.content !== undefined &&
469
+ messageWithoutTools.content !== null &&
470
+ !(typeof messageWithoutTools.content === 'string' && messageWithoutTools.content.trim() === '');
471
+ return hasContent ? [messageWithoutTools] : [];
472
+ });
473
+ }
474
+
475
+ function getKimiK3ReasoningEffortCandidate(value) {
476
+ return isPlainObject(value) ? value.effort : value;
477
+ }
478
+
479
+ function isKimiK3ReasoningOptOutCandidate(value) {
480
+ if (value === false) return true;
481
+ if (typeof value === 'string') {
482
+ const normalized = value.trim().toLowerCase();
483
+ return normalized === 'none' || normalized === 'off';
484
+ }
485
+ if (!isPlainObject(value)) return false;
486
+ const effort = String(value.effort || '').trim().toLowerCase();
487
+ const type = String(value.type || '').trim().toLowerCase();
488
+ return value.enabled === false ||
489
+ effort === 'none' ||
490
+ effort === 'off' ||
491
+ type === 'disabled' ||
492
+ type === 'off';
493
+ }
494
+
495
+ function coercePrivateModeBooleanFlag(value) {
496
+ if (typeof value === 'boolean') return value;
497
+ if (typeof value !== 'string') return undefined;
498
+ const normalized = value.trim().toLowerCase();
499
+ if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
500
+ if (['false', '0', 'no', 'off'].includes(normalized)) return false;
501
+ return undefined;
502
+ }
503
+
504
+ export function normalizePrivateModelReasoningControls(body, model) {
505
+ if (!isPrivateModeKimiK3Model(model)) return body;
506
+ const includeReasoning = coercePrivateModeBooleanFlag(body.include_reasoning);
507
+ if (includeReasoning === undefined) return body;
508
+ const reasoning = isPlainObject(body.reasoning) ? { ...body.reasoning } : {};
509
+ if (includeReasoning) reasoning.enabled = true;
510
+ else reasoning.exclude = true;
511
+ body.reasoning = reasoning;
512
+ return body;
513
+ }
514
+
515
+ export function shouldSuppressPrivateModelReasoning(body, model) {
516
+ const isKimiK3 = isPrivateModeKimiK3Model(model);
517
+ if (!isKimiK3 && model.thinkingMode !== 'deepseek-v4') return false;
518
+ const reasoningVisibilityOptOut = body.reasoningOptOut === true ||
519
+ body.exposeReasoning === false ||
520
+ body.materializeReasoning === false ||
521
+ coercePrivateModeBooleanFlag(body.include_reasoning) === false ||
522
+ (isPlainObject(body.reasoning) && body.reasoning.exclude === true);
523
+ return reasoningVisibilityOptOut || (isKimiK3 &&
524
+ [body.enable_thinking, body.thinking, body.reasoning_effort, body.reasoning]
525
+ .some((value) => isKimiK3ReasoningOptOutCandidate(value) || (
526
+ isPlainObject(value) && value.exclude === true
527
+ )));
528
+ }
529
+
530
+ function resolvePrivateModeKimiK3ReasoningEffort(body) {
531
+ const candidates = [
532
+ body.reasoningOptOut === true ? false : undefined,
533
+ body.exposeReasoning === false ? false : undefined,
534
+ body.materializeReasoning === false ? false : undefined,
535
+ body.enable_thinking,
536
+ body.thinking,
537
+ body.reasoning_effort,
538
+ body.reasoning,
539
+ ];
540
+ if (candidates.some(isKimiK3ReasoningOptOutCandidate)) return 'low';
541
+
542
+ const explicitEffort = candidates
543
+ .map(getKimiK3ReasoningEffortCandidate)
544
+ .find((value) => typeof value === 'string' && KIMI_K3_REASONING_EFFORT_LEVELS.has(value.trim().toLowerCase()));
545
+ const normalizedEffort = typeof explicitEffort === 'string'
546
+ ? explicitEffort.trim().toLowerCase()
547
+ : undefined;
548
+ const excludesReasoning = candidates.some((value) => isPlainObject(value) && value.exclude === true);
549
+ return excludesReasoning ? normalizedEffort ?? 'low' : normalizedEffort ?? 'max';
550
+ }
551
+
552
+ function applyPrivateModeKimiK3RequestParams(body) {
553
+ const reasoningEffort = resolvePrivateModeKimiK3ReasoningEffort(body);
554
+ const requestedMaxTokens = typeof body.max_tokens === 'number' && Number.isFinite(body.max_tokens)
555
+ ? body.max_tokens
556
+ : undefined;
557
+ const normalizedMaxTokens = requestedMaxTokens === undefined
558
+ ? reasoningEffort === 'max'
559
+ ? KIMI_K3_DEFAULT_MAX_COMPLETION_TOKENS
560
+ : KIMI_K3_STANDARD_DEFAULT_MAX_COMPLETION_TOKENS
561
+ : requestedMaxTokens < 0
562
+ ? KIMI_K3_MAX_COMPLETION_TOKENS
563
+ : requestedMaxTokens;
564
+ normalizePrivateModeKimiK3NamedToolChoice(body);
565
+ const promptTokenEstimate = estimatePrivateModeKimiK3PromptTokens(body);
566
+ const remainingContext = Math.max(
567
+ 1,
568
+ KIMI_K3_CONTEXT_WINDOW_TOKENS -
569
+ promptTokenEstimate -
570
+ KIMI_K3_CONTEXT_SAFETY_MARGIN_TOKENS,
571
+ );
572
+
573
+ delete body.temperature;
574
+ delete body.top_p;
575
+ delete body.n;
576
+ delete body.frequency_penalty;
577
+ delete body.presence_penalty;
578
+ delete body.logit_bias;
579
+ delete body.thinking;
580
+ delete body.enable_thinking;
581
+ delete body.reasoning;
582
+ body.reasoning_effort = reasoningEffort;
583
+ body.max_tokens = Math.min(
584
+ Math.max(1, Math.floor(normalizedMaxTokens)),
585
+ remainingContext,
586
+ );
587
+ }
588
+
589
+ function clampPrivateModeDeepSeekV4Output(body) {
590
+ const requestedMaxTokens = typeof body.max_tokens === 'number' && Number.isFinite(body.max_tokens)
591
+ ? body.max_tokens
592
+ : undefined;
593
+ if (requestedMaxTokens === undefined) return;
594
+
595
+ const normalizedMaxTokens = requestedMaxTokens < 0
596
+ ? DEEPSEEK_V4_MAX_COMPLETION_TOKENS
597
+ : requestedMaxTokens;
598
+ const promptTokenEstimate = estimatePrivateModeKimiK3PromptTokens(body);
599
+ const remainingContext = Math.max(
600
+ 1,
601
+ DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS -
602
+ promptTokenEstimate -
603
+ DEEPSEEK_V4_CONTEXT_SAFETY_MARGIN_TOKENS,
604
+ );
605
+ body.max_tokens = Math.min(
606
+ Math.max(1, Math.floor(normalizedMaxTokens)),
607
+ DEEPSEEK_V4_MAX_COMPLETION_TOKENS,
608
+ remainingContext,
609
+ );
610
+ }
611
+
612
+ export function createPrivateModeReasoningContentSuppressor() {
613
+ const openTags = ['<think>', '<thinking>', '<previous_reasoning>', '◁think▷'];
614
+ const closeTags = ['</think>', '</thinking>', '</previous_reasoning>', '◁/think▷'];
615
+ const allTags = [...openTags, ...closeTags];
616
+ const maxTagLength = Math.max(...allTags.map((tag) => tag.length));
617
+ let buffered = '';
618
+ let insideReasoning = false;
619
+
620
+ const findEarliestTag = (text, tags) => {
621
+ let earliest = null;
622
+ const normalizedText = text.toLowerCase();
623
+ for (const tag of tags) {
624
+ const index = normalizedText.indexOf(tag.toLowerCase());
625
+ if (index >= 0 && (!earliest || index < earliest.index)) earliest = { index, tag };
626
+ }
627
+ return earliest;
628
+ };
629
+ const findPartialTagIndex = (text, tags) => {
630
+ const start = Math.max(0, text.length - maxTagLength + 1);
631
+ for (let index = start; index < text.length; index += 1) {
632
+ const suffix = text.slice(index).toLowerCase();
633
+ if (tags.some((tag) => {
634
+ const normalizedTag = tag.toLowerCase();
635
+ return normalizedTag.startsWith(suffix) && normalizedTag !== suffix;
636
+ })) return index;
637
+ }
638
+ return -1;
639
+ };
640
+
641
+ return {
642
+ process(value) {
643
+ let input = buffered + String(value || '');
644
+ buffered = '';
645
+ let output = '';
646
+ while (input) {
647
+ const tags = insideReasoning ? closeTags : openTags;
648
+ const match = findEarliestTag(input, tags);
649
+ if (match) {
650
+ if (!insideReasoning) output += input.slice(0, match.index);
651
+ input = input.slice(match.index + match.tag.length);
652
+ insideReasoning = !insideReasoning;
653
+ continue;
654
+ }
655
+ const partialIndex = findPartialTagIndex(input, tags);
656
+ if (!insideReasoning) {
657
+ output += partialIndex >= 0 ? input.slice(0, partialIndex) : input;
658
+ }
659
+ if (partialIndex >= 0) buffered = input.slice(partialIndex);
660
+ return output;
661
+ }
662
+ return output;
663
+ },
664
+ flush() {
665
+ const output = insideReasoning ? '' : buffered;
666
+ buffered = '';
667
+ return output;
668
+ },
669
+ };
670
+ }
671
+
672
+ export function splitCompleteSseFrames(buffer) {
673
+ const parts = buffer.split(/\r?\n\r?\n/);
674
+ return {
675
+ frames: parts.slice(0, -1).map((frame) => frame.replace(/\r\n/g, '\n')),
676
+ remainder: parts.at(-1) || '',
677
+ };
678
+ }
679
+
680
+ const PRIVATE_MODE_SSE_CHUNK_METADATA_FIELDS = [
681
+ 'id',
682
+ 'object',
683
+ 'created',
684
+ 'model',
685
+ 'system_fingerprint',
686
+ 'service_tier',
687
+ ];
688
+
689
+ export function capturePrivateModeSseChunkMetadata(metadata, payload) {
690
+ if (!isPlainObject(metadata) || !isPlainObject(payload)) return metadata;
691
+ for (const field of PRIVATE_MODE_SSE_CHUNK_METADATA_FIELDS) {
692
+ if (payload[field] !== undefined) metadata[field] = payload[field];
693
+ }
694
+ return metadata;
695
+ }
696
+
697
+ export function buildPrivateModeSseContentDelta(content, metadata = {}) {
698
+ return JSON.stringify({
699
+ ...metadata,
700
+ choices: [{ index: 0, delta: { content } }],
701
+ });
702
+ }
703
+
704
+ export function suppressPrivateModeReasoningFromResponsePayload(
705
+ payload,
706
+ stripContent = stripPrivateModeReasoningBlocks,
707
+ ) {
708
+ if (!isPlainObject(payload)) {
709
+ throw new TypeError('Private Mode response payload must be an object.');
710
+ }
711
+ const targets = [
712
+ payload,
713
+ ...(Array.isArray(payload.choices) ? payload.choices : []),
714
+ ];
715
+ for (const target of targets) {
716
+ if (!isPlainObject(target)) continue;
717
+ const nestedTargets = [target, target.message, target.delta].filter(isPlainObject);
718
+ for (const nested of nestedTargets) {
719
+ for (const field of ASSISTANT_REASONING_MESSAGE_FIELDS) delete nested[field];
720
+ delete nested.thinking;
721
+ for (const field of ['content', 'text', 'output_text']) {
722
+ if (typeof nested[field] === 'string') {
723
+ nested[field] = stripContent(nested[field]);
724
+ } else if (Array.isArray(nested[field])) {
725
+ nested[field] = nested[field].map((part) => (
726
+ isPlainObject(part) && part.type === 'text' && typeof part.text === 'string'
727
+ ? { ...part, text: stripContent(part.text) }
728
+ : part
729
+ ));
730
+ }
731
+ }
732
+ }
733
+ }
734
+ return payload;
735
+ }
736
+
737
+ export function suppressPrivateModeReasoningFromJsonText(value) {
738
+ const parsed = JSON.parse(String(value));
739
+ suppressPrivateModeReasoningFromResponsePayload(parsed);
740
+ return JSON.stringify(parsed);
741
+ }
742
+
743
+ export function suppressPrivateModeReasoningFromSseFrame(frame, contentSuppressor, chunkMetadata) {
744
+ const lines = frame.split('\n');
745
+ const dataLineIndexes = lines.flatMap((line, index) => line.startsWith('data:') ? [index] : []);
746
+ if (dataLineIndexes.length === 0) {
747
+ const isCommentOnlyFrame =
748
+ lines.some((line) => line.startsWith(':')) &&
749
+ lines.every((line) => line === '' || line.startsWith(':'));
750
+ if (isCommentOnlyFrame) return frame;
751
+
752
+ // A stream request can still receive an ordinary JSON (or otherwise
753
+ // non-SSE) body. Reasoning opt-outs are a privacy boundary, so never pass
754
+ // a frame through unless its data payload can be parsed and sanitized.
755
+ return '';
756
+ }
757
+
758
+ const payload = dataLineIndexes
759
+ .map((index) => lines[index].slice('data:'.length).trim())
760
+ .join('\n')
761
+ .trim();
762
+ if (!payload) return frame;
763
+
764
+ const replaceDataPayload = (replacement) => lines
765
+ .flatMap((line, index) => {
766
+ if (index === dataLineIndexes[0]) return [`data: ${replacement}`];
767
+ return dataLineIndexes.includes(index) ? [] : [line];
768
+ })
769
+ .join('\n');
770
+
771
+ if (payload === '[DONE]') {
772
+ const flushedContent = contentSuppressor.flush();
773
+ if (!flushedContent) return frame;
774
+ const finalDelta = buildPrivateModeSseContentDelta(flushedContent, chunkMetadata);
775
+ return `data: ${finalDelta}\n\n${replaceDataPayload('[DONE]')}`;
776
+ }
777
+
778
+ try {
779
+ const parsed = JSON.parse(payload);
780
+ capturePrivateModeSseChunkMetadata(chunkMetadata, parsed);
781
+ suppressPrivateModeReasoningFromResponsePayload(
782
+ parsed,
783
+ (content) => contentSuppressor.process(content),
784
+ );
785
+ const terminalChoice = Array.isArray(parsed.choices) && parsed.choices.find(
786
+ (choice) => choice && choice.finish_reason !== undefined && choice.finish_reason !== null,
787
+ );
788
+ if (!terminalChoice) return replaceDataPayload(JSON.stringify(parsed));
789
+
790
+ const flushedContent = contentSuppressor.flush();
791
+ if (!flushedContent) return replaceDataPayload(JSON.stringify(parsed));
792
+
793
+ let visibleContent = '';
794
+ if (isPlainObject(terminalChoice.delta) && typeof terminalChoice.delta.content === 'string') {
795
+ visibleContent = terminalChoice.delta.content;
796
+ terminalChoice.delta.content = '';
797
+ } else if (typeof terminalChoice.text === 'string') {
798
+ visibleContent = terminalChoice.text;
799
+ terminalChoice.text = '';
800
+ }
801
+ const finalDelta = buildPrivateModeSseContentDelta(
802
+ `${visibleContent}${flushedContent}`,
803
+ chunkMetadata,
804
+ );
805
+ return `data: ${finalDelta}\n\n${replaceDataPayload(JSON.stringify(parsed))}`;
806
+ } catch {
807
+ // Reasoning opt-outs are a privacy boundary. Never pass an unparseable
808
+ // upstream data frame through because it may contain unsanitized reasoning.
809
+ return '';
810
+ }
811
+ }
812
+
166
813
  function buildJsonSchemaPromptSuffix(responseFormat) {
167
814
  if (!isPlainObject(responseFormat?.json_schema)) return null;
168
815
  const schemaWrapper = responseFormat.json_schema;
@@ -240,12 +887,22 @@ function normalizeStreamOptions(body) {
240
887
  }
241
888
 
242
889
  export function applyPrivateModelRequestMutations(body, model) {
243
- stripPrivateModeReasoningFromMessages(body);
890
+ if (isPrivateModeKimiK3Model(model)) {
891
+ normalizePrivateModelReasoningControls(body, model);
892
+ normalizePrivateModeKimiK3ReasoningFromMessages(body);
893
+ normalizePrivateModeKimiK3DynamicToolMessages(body);
894
+ } else {
895
+ stripPrivateModeReasoningFromMessages(body);
896
+ }
244
897
  normalizeMaxTokenAliases(body);
245
-
246
898
  body.model = model.upstreamModel;
247
899
  normalizeStreamOptions(body);
248
900
  applyTinfoilCompatibilityMutations(body, model);
901
+ if (isPrivateModeKimiK3Model(model)) {
902
+ applyPrivateModeKimiK3RequestParams(body);
903
+ } else if (model.thinkingMode === 'deepseek-v4') {
904
+ clampPrivateModeDeepSeekV4Output(body);
905
+ }
249
906
 
250
907
  if (model.thinkingMode === 'gemma') {
251
908
  body.chat_template_kwargs = {
@@ -254,16 +911,21 @@ export function applyPrivateModelRequestMutations(body, model) {
254
911
  };
255
912
  delete body.thinking;
256
913
  delete body.reasoning_effort;
257
- } else if (model.thinkingMode === 'kimi-k2.6' || model.thinkingMode === 'glm-5.2') {
914
+ } else if (
915
+ model.thinkingMode === 'glm-5.2' ||
916
+ model.thinkingMode === 'deepseek-v4'
917
+ ) {
258
918
  const thinkingEnabled = shouldEnableThinking(body, model);
919
+ const requestedReasoningEffort = body.reasoning_effort
920
+ ?? (isPlainObject(body.reasoning) ? body.reasoning.effort : undefined);
259
921
  body.chat_template_kwargs = {
260
922
  ...mergeChatTemplateKwargs(body),
261
923
  thinking: thinkingEnabled,
262
924
  };
263
925
 
264
- if (model.thinkingMode === 'glm-5.2' && thinkingEnabled) {
926
+ if (thinkingEnabled) {
265
927
  body.chat_template_kwargs.reasoning_effort =
266
- normalizeDeepSeekV4ReasoningEffort(body.reasoning_effort);
928
+ normalizeDeepSeekV4ReasoningEffort(requestedReasoningEffort);
267
929
  } else {
268
930
  delete body.chat_template_kwargs.reasoning_effort;
269
931
  }
@@ -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 parsed = Number(process.env.NANOGPT_PRIVATE_CLIENT_IDLE_RESET_MS || '');
5
- if (Number.isFinite(parsed) && parsed > 0) return Math.floor(parsed);
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 > 0
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
- && idleMs >= 0
28
- && idleMs < idleResetMs
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 { applyPrivateModelRequestMutations } from './requestTransforms.js';
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) res.write(Buffer.from(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
- const responseBody = Buffer.from(await response.arrayBuffer());
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. Never expose decrypted upstream
3
- // response bodies or exception text to local proxy clients.
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
- export async function readErrorMessage(response, fallback) {
23
- void fallback;
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. Its contents remain discarded.
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
- return privateModeProviderFailureMessage(response?.status);
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
  }
@@ -1,14 +1,26 @@
1
1
  [
2
2
  {
3
- "id": "private/kimi-k2-6",
4
- "name": "Kimi K2.6 Private",
5
- "upstreamModel": "kimi-k2-6",
6
- "billingModel": "TEE/kimi-k2-6",
7
- "teeTargetModel": "kimi-k2-6",
8
- "thinkingMode": "kimi-k2.6",
9
- "created": 1764547200,
3
+ "id": "private/deepseek-v4-flash",
4
+ "name": "DeepSeek V4 Flash Private",
5
+ "upstreamModel": "deepseek-v4-flash",
6
+ "billingModel": "private/deepseek-v4-flash",
7
+ "teeTargetModel": "deepseek-v4-flash",
8
+ "thinkingMode": "deepseek-v4",
9
+ "maxOutputTokens": 1048576,
10
+ "created": 1786406400,
11
+ "ownedBy": "nanogpt-private-mode",
12
+ "aliases": ["TEE/deepseek-v4-flash"]
13
+ },
14
+ {
15
+ "id": "private/kimi-k3",
16
+ "name": "Kimi K3 Private",
17
+ "upstreamModel": "kimi-k3",
18
+ "billingModel": "TEE/kimi-k3",
19
+ "teeTargetModel": "kimi-k3",
20
+ "maxOutputTokens": 1048576,
21
+ "created": 1786147200,
10
22
  "ownedBy": "nanogpt-private-mode",
11
- "aliases": ["private/kimi-k2.6", "TEE/kimi-k2-6", "TEE/kimi-k2.6"]
23
+ "aliases": ["TEE/kimi-k3"]
12
24
  },
13
25
  {
14
26
  "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.2",
3
+ "version": "0.2.6",
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
- "scripts": {
35
- "start": "node ./bin/nanogpt-private-mode.js"
36
- }
37
- }
36
+ "license": "MIT"
37
+ }