@nanogpt/private-mode 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  OpenAI-compatible localhost proxy for NanoGPT Private Mode with supported TEE models.
4
4
 
5
+ Requires Node.js 22 or later.
6
+
5
7
  ```bash
6
8
  NANOGPT_API_KEY=sk-your-key npx @nanogpt/private-mode
7
9
  ```
@@ -0,0 +1,9 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ export function buildPrivateModeCacheScopeProof(cacheScope) {
4
+ const normalized = String(cacheScope || '').trim();
5
+ if (!/^[a-f0-9]{64}$/i.test(normalized)) {
6
+ throw new Error('Cannot prove an invalid Private Mode cache scope.');
7
+ }
8
+ return createHash('sha256').update(normalized, 'utf8').digest('hex');
9
+ }
@@ -28,20 +28,29 @@ function normalizeHostForOrigin(host) {
28
28
  return trimmed;
29
29
  }
30
30
 
31
+ function addAllowedOrigin(allowedOrigins, origin) {
32
+ const normalizedOrigin = normalizePrivateModeOrigin(origin);
33
+ if (normalizedOrigin) allowedOrigins.add(normalizedOrigin);
34
+ return normalizedOrigin;
35
+ }
36
+
37
+ function addDefaultLoopbackOrigins(allowedOrigins, host, port) {
38
+ for (const defaultHost of DEFAULT_LOCAL_HOSTS) {
39
+ allowedOrigins.add(`http://${defaultHost}:${port}`);
40
+ }
41
+
42
+ const normalizedHost = normalizeHostForOrigin(host);
43
+ if (normalizedHost) {
44
+ addAllowedOrigin(allowedOrigins, `http://${normalizedHost}:${port}`);
45
+ }
46
+ }
47
+
31
48
  export function buildPrivateModeOriginPolicy(options = {}) {
32
49
  const port = Number.parseInt(String(options.port || '8787'), 10);
33
50
  const allowedOrigins = new Set();
34
51
 
35
52
  if (Number.isFinite(port) && port > 0) {
36
- for (const host of DEFAULT_LOCAL_HOSTS) {
37
- allowedOrigins.add(`http://${host}:${port}`);
38
- }
39
-
40
- const normalizedHost = normalizeHostForOrigin(options.host);
41
- if (normalizedHost) {
42
- const origin = normalizePrivateModeOrigin(`http://${normalizedHost}:${port}`);
43
- if (origin) allowedOrigins.add(origin);
44
- }
53
+ addDefaultLoopbackOrigins(allowedOrigins, options.host, port);
45
54
  }
46
55
 
47
56
  const configuredOrigins = [
@@ -50,11 +59,9 @@ export function buildPrivateModeOriginPolicy(options = {}) {
50
59
  ];
51
60
 
52
61
  for (const configuredOrigin of configuredOrigins) {
53
- const origin = normalizePrivateModeOrigin(configuredOrigin);
54
- if (!origin) {
62
+ if (!addAllowedOrigin(allowedOrigins, configuredOrigin)) {
55
63
  throw new Error(`Invalid Private Mode browser origin "${configuredOrigin}". Use an http(s) origin, not a wildcard.`);
56
64
  }
57
- allowedOrigins.add(origin);
58
65
  }
59
66
 
60
67
  return {
@@ -6,6 +6,11 @@ const ASSISTANT_REASONING_MESSAGE_FIELDS = [
6
6
  'reasoning_content',
7
7
  'reasoning_details',
8
8
  ];
9
+ const MAX_TOKEN_ALIAS_FIELDS = [
10
+ 'max_completion_tokens',
11
+ 'maxCompletionTokens',
12
+ 'maxTokens',
13
+ ];
9
14
  const PRIVATE_TINFOIL_CHAT_COMPLETION_BODY_FIELDS = new Set([
10
15
  'chat_template_kwargs',
11
16
  'frequency_penalty',
@@ -209,34 +214,37 @@ function applyTinfoilCompatibilityMutations(body, model) {
209
214
  }
210
215
  }
211
216
 
212
- export function applyPrivateModelRequestMutations(body, model) {
213
- stripPrivateModeReasoningFromMessages(body);
214
-
215
- if (body.max_tokens === undefined && body.max_completion_tokens !== undefined) {
216
- body.max_tokens = body.max_completion_tokens;
217
- }
218
- if (body.max_tokens === undefined && body.maxCompletionTokens !== undefined) {
219
- body.max_tokens = body.maxCompletionTokens;
220
- }
221
- if (body.max_tokens === undefined && body.maxTokens !== undefined) {
222
- body.max_tokens = body.maxTokens;
217
+ function normalizeMaxTokenAliases(body) {
218
+ for (const key of MAX_TOKEN_ALIAS_FIELDS) {
219
+ if (body.max_tokens === undefined && body[key] !== undefined) {
220
+ body.max_tokens = body[key];
221
+ }
222
+ delete body[key];
223
223
  }
224
- delete body.max_completion_tokens;
225
- delete body.maxCompletionTokens;
226
- delete body.maxTokens;
224
+ }
227
225
 
228
- body.model = model.upstreamModel;
226
+ function normalizeStreamOptions(body) {
229
227
  if (body.stream !== undefined) {
230
228
  body.stream = body.stream === true;
231
229
  }
230
+
232
231
  if (body.stream === true) {
233
232
  body.stream_options = {
234
233
  ...(body.stream_options && typeof body.stream_options === 'object' ? body.stream_options : {}),
235
234
  include_usage: true,
236
235
  };
237
- } else {
238
- delete body.stream_options;
236
+ return;
239
237
  }
238
+
239
+ delete body.stream_options;
240
+ }
241
+
242
+ export function applyPrivateModelRequestMutations(body, model) {
243
+ stripPrivateModeReasoningFromMessages(body);
244
+ normalizeMaxTokenAliases(body);
245
+
246
+ body.model = model.upstreamModel;
247
+ normalizeStreamOptions(body);
240
248
  applyTinfoilCompatibilityMutations(body, model);
241
249
 
242
250
  if (model.thinkingMode === 'gemma') {
@@ -246,14 +254,14 @@ export function applyPrivateModelRequestMutations(body, model) {
246
254
  };
247
255
  delete body.thinking;
248
256
  delete body.reasoning_effort;
249
- } else if (model.thinkingMode === 'deepseek-v4-pro' || model.thinkingMode === 'glm-5.2') {
257
+ } else if (model.thinkingMode === 'kimi-k2.6' || model.thinkingMode === 'glm-5.2') {
250
258
  const thinkingEnabled = shouldEnableThinking(body, model);
251
259
  body.chat_template_kwargs = {
252
260
  ...mergeChatTemplateKwargs(body),
253
261
  thinking: thinkingEnabled,
254
262
  };
255
263
 
256
- if (thinkingEnabled) {
264
+ if (model.thinkingMode === 'glm-5.2' && thinkingEnabled) {
257
265
  body.chat_template_kwargs.reasoning_effort =
258
266
  normalizeDeepSeekV4ReasoningEffort(body.reasoning_effort);
259
267
  } else {
package/lib/server.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createServer } from 'node:http';
2
2
  import { readFileSync } from 'node:fs';
3
3
 
4
+ import { buildPrivateModeCacheScopeProof } from './cacheScope.js';
4
5
  import {
5
6
  buildPrivateModeOriginPolicy,
6
7
  getCorsHeadersForRequest,
@@ -142,40 +143,49 @@ function readBody(req) {
142
143
 
143
144
  function copyLocalHeaders(req) {
144
145
  const headers = {};
145
- const teamId = req.headers['x-team-id'];
146
+ const teamId = req?.headers?.['x-team-id'];
146
147
  if (typeof teamId === 'string' && teamId.trim()) {
147
148
  headers['x-team-id'] = teamId.trim();
148
149
  }
149
150
  return headers;
150
151
  }
151
152
 
152
- function createSecureState(apiBase) {
153
- let clientPromise = null;
153
+ function createSecureState(apiBase, apiKey) {
154
+ let clientState = null;
154
155
  let verificationDocument = null;
155
156
  let verificationError = null;
156
157
 
157
- async function getClient() {
158
- if (!clientPromise) {
159
- clientPromise = import('tinfoil')
160
- .then(async ({ SecureClient }) => {
161
- const baseURL = `${apiBase}/api/v1/private/tinfoil/`;
162
- const client = new SecureClient({
163
- baseURL,
164
- attestationBundleURL: `${apiBase}/api/v1/private/tinfoil`,
165
- transport: 'ehbp',
166
- });
167
- await client.ready();
168
- verificationDocument = client.getVerificationDocument();
169
- verificationError = null;
170
- return client;
171
- })
172
- .catch((error) => {
173
- clientPromise = null;
174
- verificationError = error;
175
- throw error;
176
- });
158
+ async function getClient(userCacheSecret = clientState?.userCacheSecret) {
159
+ if (!userCacheSecret) {
160
+ throw new Error('NanoGPT preflight must establish cache isolation before attestation.');
161
+ }
162
+ if (clientState?.userCacheSecret === userCacheSecret) {
163
+ return clientState.promise;
164
+ }
165
+
166
+ const promise = import('tinfoil').then(async ({ SecureClient }) => {
167
+ const baseURL = `${apiBase}/api/v1/private/tinfoil/`;
168
+ const client = new SecureClient({
169
+ baseURL,
170
+ attestationBundleURL: `${apiBase}/api/v1/private/tinfoil`,
171
+ transport: 'ehbp',
172
+ userCacheSecret,
173
+ });
174
+ await client.ready();
175
+ verificationDocument = client.getVerificationDocument();
176
+ verificationError = null;
177
+ return client;
178
+ });
179
+ const nextState = { userCacheSecret, promise };
180
+ clientState = nextState;
181
+
182
+ try {
183
+ return await promise;
184
+ } catch (error) {
185
+ if (clientState === nextState) clientState = null;
186
+ verificationError = error;
187
+ throw error;
177
188
  }
178
- return clientPromise;
179
189
  }
180
190
 
181
191
  return {
@@ -206,7 +216,27 @@ async function runPreflight({ apiBase, apiKey, model, req, requestBodyBytes }) {
206
216
  body: JSON.stringify(preflightBody),
207
217
  });
208
218
 
209
- if (response.ok) return { ok: true };
219
+ if (response.ok) {
220
+ try {
221
+ const data = await response.json();
222
+ const cacheScope = typeof data?.cacheScope === 'string' ? data.cacheScope.trim() : '';
223
+ if (/^[a-f0-9]{64}$/i.test(cacheScope)) {
224
+ return { ok: true, cacheScope };
225
+ }
226
+ } catch {}
227
+
228
+ return {
229
+ ok: false,
230
+ status: 502,
231
+ body: {
232
+ error: {
233
+ message: 'NanoGPT preflight did not establish cache isolation.',
234
+ type: 'api_error',
235
+ code: 'cache_scope_unavailable',
236
+ },
237
+ },
238
+ };
239
+ }
210
240
 
211
241
  let errorBody = null;
212
242
  try {
@@ -223,6 +253,22 @@ async function runPreflight({ apiBase, apiKey, model, req, requestBodyBytes }) {
223
253
  return { ok: false, status: response.status, body: errorBody };
224
254
  }
225
255
 
256
+ async function getPreflightedClient({ apiBase, apiKey, secureState, req, model = MODELS[0] }) {
257
+ const preflight = await runPreflight({
258
+ apiBase,
259
+ apiKey,
260
+ model,
261
+ req,
262
+ requestBodyBytes: 0,
263
+ });
264
+ if (!preflight.ok) {
265
+ throw new Error(
266
+ preflight.body?.error?.message || `NanoGPT preflight failed with HTTP ${preflight.status}`,
267
+ );
268
+ }
269
+ return secureState.getClient(preflight.cacheScope);
270
+ }
271
+
226
272
  async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, corsHeaders }) {
227
273
  let rawBody;
228
274
  try {
@@ -288,7 +334,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
288
334
  });
289
335
 
290
336
  try {
291
- const client = await secureState.getClient();
337
+ const client = await secureState.getClient(preflight.cacheScope);
292
338
  response = await client.fetch(`${apiBase}/api/v1/private/tinfoil/v1/chat/completions`, {
293
339
  method: 'POST',
294
340
  headers: {
@@ -297,6 +343,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
297
343
  accept: privateStreamRequested ? 'text/event-stream' : 'application/json',
298
344
  'x-nanogpt-private-model': model.id,
299
345
  'x-nanogpt-private-stream': privateStreamRequested ? 'true' : 'false',
346
+ 'x-nanogpt-private-cache-scope': buildPrivateModeCacheScopeProof(preflight.cacheScope),
300
347
  'x-query-source': 'api',
301
348
  ...copyLocalHeaders(req),
302
349
  },
@@ -309,7 +356,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
309
356
  const message = error instanceof Error ? error.message : String(error);
310
357
  jsonResponse(res, 502, {
311
358
  error: {
312
- message: `Private Mode request failed: ${normalizePrivateModeUpstreamErrorMessage(message, message)}`,
359
+ message: normalizePrivateModeUpstreamErrorMessage(message, 'Private Mode request failed.'),
313
360
  type: 'api_error',
314
361
  code: 'private_mode_request_failed',
315
362
  },
@@ -401,7 +448,7 @@ function handleOptions(req, res, corsHeaders) {
401
448
 
402
449
  export async function startPrivateModeProxy(options) {
403
450
  const apiBase = normalizeApiBase(options.apiBase);
404
- const secureState = createSecureState(apiBase);
451
+ const secureState = createSecureState(apiBase, options.apiKey);
405
452
  const localBase = `http://${options.host}:${options.port}/v1`;
406
453
  const originPolicy = buildPrivateModeOriginPolicy(options);
407
454
 
@@ -442,7 +489,12 @@ export async function startPrivateModeProxy(options) {
442
489
 
443
490
  if (req.method === 'GET' && url.pathname === '/v1/private-mode/attestation') {
444
491
  try {
445
- await secureState.getClient();
492
+ await getPreflightedClient({
493
+ apiBase,
494
+ apiKey: options.apiKey,
495
+ secureState,
496
+ req,
497
+ });
446
498
  jsonResponse(res, 200, secureState.getVerificationState(), corsHeaders);
447
499
  } catch (error) {
448
500
  jsonResponse(res, 502, {
@@ -508,7 +560,11 @@ The verified TEE target and this local proxy can see plaintext.
508
560
  }
509
561
 
510
562
  if (options.warmAttestation !== false) {
511
- secureState.getClient()
563
+ getPreflightedClient({
564
+ apiBase,
565
+ apiKey: options.apiKey,
566
+ secureState,
567
+ })
512
568
  .then((client) => {
513
569
  const doc = client.getVerificationDocument();
514
570
  process.stdout.write(`Attestation: verified=${doc.securityVerified === true}; enclave=${doc.enclaveHost || client.getEnclaveURL() || 'unknown'}\n`);
@@ -1,45 +1,30 @@
1
- // Keep upstream error normalization in sync with lib/privateMode/tinfoilBrowserClient.ts.
2
- const UPSTREAM_OBJECT_SHAPE_ERROR_PATTERN = /object has no attribute ['"]get['"]/i;
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.
3
4
  const MISSING_EHBP_RESPONSE_NONCE_PATTERN = /missing\s+ehbp-response-nonce\s+header/i;
5
+ const PRIVATE_MODE_REFUND_NOTICE = 'Any reserved balance will be released or refunded.';
4
6
 
5
- export function normalizePrivateModeUpstreamErrorMessage(message, fallback) {
6
- if (UPSTREAM_OBJECT_SHAPE_ERROR_PATTERN.test(message)) {
7
- return 'Private Mode upstream returned an internal error while processing request metadata. Retry or choose another Private Mode model.';
7
+ export function privateModeProviderFailureMessage(status) {
8
+ if (status === 429) {
9
+ return `Private Mode is temporarily rate-limited. ${PRIVATE_MODE_REFUND_NOTICE}`;
8
10
  }
9
- if (MISSING_EHBP_RESPONSE_NONCE_PATTERN.test(message)) {
10
- return 'Private Mode did not receive an encrypted response from Tinfoil. The private request may have been rejected before completion.';
11
+ return `The Private Mode provider temporarily failed. ${PRIVATE_MODE_REFUND_NOTICE}`;
12
+ }
13
+
14
+ export function normalizePrivateModeUpstreamErrorMessage(message, fallback) {
15
+ void fallback;
16
+ if (MISSING_EHBP_RESPONSE_NONCE_PATTERN.test(String(message || ''))) {
17
+ return `Private Mode could not verify the provider response. ${PRIVATE_MODE_REFUND_NOTICE}`;
11
18
  }
12
- return message || fallback;
19
+ return privateModeProviderFailureMessage();
13
20
  }
14
21
 
15
22
  export async function readErrorMessage(response, fallback) {
16
- let message = fallback;
23
+ void fallback;
17
24
  try {
18
- const data = await response.clone().json();
19
- if (data && typeof data === 'object' && !Array.isArray(data)) {
20
- const error = data.error && typeof data.error === 'object' && !Array.isArray(data.error)
21
- ? data.error
22
- : {};
23
- message = (
24
- typeof error.message === 'string' && error.message.trim()
25
- ? error.message
26
- : typeof data.message === 'string' && data.message.trim()
27
- ? data.message
28
- : fallback
29
- );
30
- } else if (typeof data === 'string' && data.trim()) {
31
- message = data;
32
- } else if (typeof data === 'number' || typeof data === 'boolean') {
33
- message = String(data);
34
- }
25
+ await response?.body?.cancel();
35
26
  } catch {
36
- try {
37
- const text = await response.text();
38
- message = text || fallback;
39
- } catch {
40
- message = fallback;
41
- }
27
+ // The body may already be locked or closed. Its contents remain discarded.
42
28
  }
43
-
44
- return normalizePrivateModeUpstreamErrorMessage(message, fallback);
29
+ return privateModeProviderFailureMessage(response?.status);
45
30
  }
@@ -5,6 +5,7 @@
5
5
  "upstreamModel": "kimi-k2-6",
6
6
  "billingModel": "TEE/kimi-k2-6",
7
7
  "teeTargetModel": "kimi-k2-6",
8
+ "thinkingMode": "kimi-k2.6",
8
9
  "created": 1764547200,
9
10
  "ownedBy": "nanogpt-private-mode",
10
11
  "aliases": ["private/kimi-k2.6", "TEE/kimi-k2-6", "TEE/kimi-k2.6"]
@@ -72,27 +73,5 @@
72
73
  "created": 1764547200,
73
74
  "ownedBy": "nanogpt-private-mode",
74
75
  "aliases": ["TEE/gemma4-31b:thinking", "gemma4-31b:thinking"]
75
- },
76
- {
77
- "id": "private/deepseek-v4-pro",
78
- "name": "DeepSeek V4 Pro Private",
79
- "upstreamModel": "deepseek-v4-pro",
80
- "billingModel": "TEE/deepseek-v4-pro",
81
- "teeTargetModel": "deepseek-v4-pro",
82
- "thinkingMode": "deepseek-v4-pro",
83
- "created": 1764547200,
84
- "ownedBy": "nanogpt-private-mode",
85
- "aliases": ["TEE/deepseek-v4-pro", "deepseek-v4-pro"]
86
- },
87
- {
88
- "id": "private/deepseek-v4-pro:thinking",
89
- "name": "DeepSeek V4 Pro Thinking Private",
90
- "upstreamModel": "deepseek-v4-pro",
91
- "billingModel": "TEE/deepseek-v4-pro:thinking",
92
- "teeTargetModel": "deepseek-v4-pro",
93
- "thinkingMode": "deepseek-v4-pro",
94
- "created": 1764547200,
95
- "ownedBy": "nanogpt-private-mode",
96
- "aliases": ["TEE/deepseek-v4-pro:thinking", "deepseek-v4-pro:thinking"]
97
76
  }
98
77
  ]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanogpt/private-mode",
3
- "version": "0.1.4",
3
+ "version": "0.2.0",
4
4
  "description": "OpenAI-compatible localhost proxy for NanoGPT Private Mode.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "bin",
15
+ "lib/cacheScope.js",
15
16
  "lib/originPolicy.js",
16
17
  "lib/requestTransforms.js",
17
18
  "lib/server.js",
@@ -25,10 +26,11 @@
25
26
  "start": "node ./bin/nanogpt-private-mode.js"
26
27
  },
27
28
  "dependencies": {
28
- "tinfoil": "1.1.3"
29
+ "ai": "6.0.220",
30
+ "tinfoil": "1.1.12"
29
31
  },
30
32
  "engines": {
31
- "node": ">=20"
33
+ "node": ">=22"
32
34
  },
33
35
  "license": "MIT"
34
36
  }