@nanogpt/private-mode 0.1.1 → 0.1.3
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 +5 -5
- package/lib/requestTransforms.js +46 -4
- package/lib/server.js +23 -4
- package/lib/serverErrorNormalization.js +45 -0
- package/lib/statusContract.js +2 -2
- package/models/{tinfoil.json → private-tee.json} +22 -32
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# NanoGPT Private Mode
|
|
2
2
|
|
|
3
|
-
OpenAI-compatible localhost proxy for NanoGPT Private Mode with
|
|
3
|
+
OpenAI-compatible localhost proxy for NanoGPT Private Mode with supported TEE models.
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
NANOGPT_API_KEY=sk-your-key npx @nanogpt/private-mode
|
|
@@ -28,17 +28,17 @@ const response = await client.chat.completions.create({
|
|
|
28
28
|
});
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
-
The local proxy verifies
|
|
31
|
+
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.
|
|
32
32
|
|
|
33
|
-
NanoGPT can see account identity, selected private model, selected
|
|
33
|
+
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.
|
|
34
34
|
|
|
35
|
-
NanoGPT's web app can also use these models without running this local proxy. Select an eligible
|
|
35
|
+
NanoGPT's web app can also use these models without running this local proxy. Select an eligible Private Mode model and use the Private Mode control in the model picker. This package is for API clients, CLIs, agents, and other OpenAI-compatible tools.
|
|
36
36
|
|
|
37
37
|
The web-app toggle is narrower than the proxy path in v1. It supports text chat and model settings, and disables attachments, web search, URL-scraped content, project tools, multi-model chat, Context Memory injection, quick replies, and automatic title generation for private turns.
|
|
38
38
|
|
|
39
39
|
In the hosted web app, decrypted Private Mode turns remain in local browser history. Cloud conversation sync is blocked for Private Mode chats unless password-based end-to-end sync is enabled; the recoverable default sync mode is not used for those chats.
|
|
40
40
|
|
|
41
|
-
Private Mode also requires enough NanoGPT balance and API-key spend-limit headroom before dispatch.
|
|
41
|
+
Private Mode also requires enough NanoGPT balance and API-key spend-limit headroom before dispatch. For streaming calls, NanoGPT may precharge a conservative reserve before dispatch and refund unused balance after verified usage metadata is available.
|
|
42
42
|
|
|
43
43
|
Useful local checks:
|
|
44
44
|
|
package/lib/requestTransforms.js
CHANGED
|
@@ -6,6 +6,32 @@ const ASSISTANT_REASONING_MESSAGE_FIELDS = [
|
|
|
6
6
|
'reasoning_content',
|
|
7
7
|
'reasoning_details',
|
|
8
8
|
];
|
|
9
|
+
const PRIVATE_TINFOIL_CHAT_COMPLETION_BODY_FIELDS = new Set([
|
|
10
|
+
'chat_template_kwargs',
|
|
11
|
+
'frequency_penalty',
|
|
12
|
+
'function_call',
|
|
13
|
+
'functions',
|
|
14
|
+
'logit_bias',
|
|
15
|
+
'logprobs',
|
|
16
|
+
'max_tokens',
|
|
17
|
+
'messages',
|
|
18
|
+
'model',
|
|
19
|
+
'n',
|
|
20
|
+
'parallel_tool_calls',
|
|
21
|
+
'presence_penalty',
|
|
22
|
+
'reasoning_effort',
|
|
23
|
+
'response_format',
|
|
24
|
+
'seed',
|
|
25
|
+
'stop',
|
|
26
|
+
'stream',
|
|
27
|
+
'stream_options',
|
|
28
|
+
'temperature',
|
|
29
|
+
'tool_choice',
|
|
30
|
+
'tools',
|
|
31
|
+
'top_logprobs',
|
|
32
|
+
'top_p',
|
|
33
|
+
'user',
|
|
34
|
+
]);
|
|
9
35
|
|
|
10
36
|
// Keep request mutation behavior in sync with lib/privateMode/tinfoilBrowserClient.ts
|
|
11
37
|
// for explicit fields. The local proxy preserves OpenAI's non-streaming default.
|
|
@@ -79,6 +105,14 @@ function mergeChatTemplateKwargs(body) {
|
|
|
79
105
|
return isPlainObject(body.chat_template_kwargs) ? body.chat_template_kwargs : {};
|
|
80
106
|
}
|
|
81
107
|
|
|
108
|
+
function stripUnsupportedPrivateTinfoilFields(body) {
|
|
109
|
+
for (const key of Object.keys(body)) {
|
|
110
|
+
if (!PRIVATE_TINFOIL_CHAT_COMPLETION_BODY_FIELDS.has(key)) {
|
|
111
|
+
delete body[key];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
82
116
|
function stripPrivateModeReasoningBlocks(value) {
|
|
83
117
|
return value
|
|
84
118
|
.replace(/◁think▷/g, '<think>')
|
|
@@ -178,9 +212,17 @@ function applyTinfoilCompatibilityMutations(body, model) {
|
|
|
178
212
|
export function applyPrivateModelRequestMutations(body, model) {
|
|
179
213
|
stripPrivateModeReasoningFromMessages(body);
|
|
180
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
|
+
}
|
|
181
221
|
if (body.max_tokens === undefined && body.maxTokens !== undefined) {
|
|
182
222
|
body.max_tokens = body.maxTokens;
|
|
183
223
|
}
|
|
224
|
+
delete body.max_completion_tokens;
|
|
225
|
+
delete body.maxCompletionTokens;
|
|
184
226
|
delete body.maxTokens;
|
|
185
227
|
|
|
186
228
|
body.model = model.upstreamModel;
|
|
@@ -192,6 +234,8 @@ export function applyPrivateModelRequestMutations(body, model) {
|
|
|
192
234
|
...(body.stream_options && typeof body.stream_options === 'object' ? body.stream_options : {}),
|
|
193
235
|
include_usage: true,
|
|
194
236
|
};
|
|
237
|
+
} else {
|
|
238
|
+
delete body.stream_options;
|
|
195
239
|
}
|
|
196
240
|
applyTinfoilCompatibilityMutations(body, model);
|
|
197
241
|
|
|
@@ -202,10 +246,7 @@ export function applyPrivateModelRequestMutations(body, model) {
|
|
|
202
246
|
};
|
|
203
247
|
delete body.thinking;
|
|
204
248
|
delete body.reasoning_effort;
|
|
205
|
-
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (model.thinkingMode === 'deepseek-v4-pro') {
|
|
249
|
+
} else if (model.thinkingMode === 'deepseek-v4-pro') {
|
|
209
250
|
const thinkingEnabled = shouldEnableThinking(body, model);
|
|
210
251
|
body.chat_template_kwargs = {
|
|
211
252
|
...mergeChatTemplateKwargs(body),
|
|
@@ -223,5 +264,6 @@ export function applyPrivateModelRequestMutations(body, model) {
|
|
|
223
264
|
delete body.reasoning_effort;
|
|
224
265
|
}
|
|
225
266
|
|
|
267
|
+
stripUnsupportedPrivateTinfoilFields(body);
|
|
226
268
|
return body;
|
|
227
269
|
}
|
package/lib/server.js
CHANGED
|
@@ -6,10 +6,14 @@ import {
|
|
|
6
6
|
getCorsHeadersForRequest,
|
|
7
7
|
} from './originPolicy.js';
|
|
8
8
|
import { applyPrivateModelRequestMutations } from './requestTransforms.js';
|
|
9
|
+
import {
|
|
10
|
+
normalizePrivateModeUpstreamErrorMessage,
|
|
11
|
+
readErrorMessage,
|
|
12
|
+
} from './serverErrorNormalization.js';
|
|
9
13
|
import { buildPrivateModeStatusContract } from './statusContract.js';
|
|
10
14
|
|
|
11
15
|
const MODELS = JSON.parse(
|
|
12
|
-
readFileSync(new URL('../models/
|
|
16
|
+
readFileSync(new URL('../models/private-tee.json', import.meta.url), 'utf8'),
|
|
13
17
|
);
|
|
14
18
|
|
|
15
19
|
function readMaxBodyBytes() {
|
|
@@ -66,7 +70,7 @@ function openAIModelList() {
|
|
|
66
70
|
export function privateModeStatus(apiBase, secureState, localBase, originPolicy) {
|
|
67
71
|
return {
|
|
68
72
|
ok: true,
|
|
69
|
-
mode: '
|
|
73
|
+
mode: 'private_tee',
|
|
70
74
|
apiBase,
|
|
71
75
|
local_base_url: localBase,
|
|
72
76
|
models_path: '/v1/models',
|
|
@@ -302,9 +306,10 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
302
306
|
} catch (error) {
|
|
303
307
|
responseComplete = true;
|
|
304
308
|
if (res.destroyed) return;
|
|
309
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
305
310
|
jsonResponse(res, 502, {
|
|
306
311
|
error: {
|
|
307
|
-
message: `Private Mode request failed: ${
|
|
312
|
+
message: `Private Mode request failed: ${normalizePrivateModeUpstreamErrorMessage(message, message)}`,
|
|
308
313
|
type: 'api_error',
|
|
309
314
|
code: 'private_mode_request_failed',
|
|
310
315
|
},
|
|
@@ -321,6 +326,20 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
321
326
|
const privateMode = response.headers.get('x-nanogpt-private-mode');
|
|
322
327
|
if (privateMode) headers['x-nanogpt-private-mode'] = privateMode;
|
|
323
328
|
|
|
329
|
+
if (!response.ok) {
|
|
330
|
+
responseComplete = true;
|
|
331
|
+
const errorHeaders = { ...headers };
|
|
332
|
+
delete errorHeaders['content-type'];
|
|
333
|
+
jsonResponse(res, response.status, {
|
|
334
|
+
error: {
|
|
335
|
+
message: await readErrorMessage(response, `Private Mode request failed with HTTP ${response.status}`),
|
|
336
|
+
type: 'api_error',
|
|
337
|
+
code: 'private_mode_upstream_failed',
|
|
338
|
+
},
|
|
339
|
+
}, errorHeaders);
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
324
343
|
if (privateStreamRequested && response.body) {
|
|
325
344
|
res.writeHead(response.status, headers);
|
|
326
345
|
const reader = response.body.getReader();
|
|
@@ -484,7 +503,7 @@ Allowed browser origins: ${originPolicy.allowedOrigins.join(', ')}
|
|
|
484
503
|
|
|
485
504
|
Request and response bodies are encrypted after they leave this local proxy.
|
|
486
505
|
NanoGPT can see your account, private model header, timing, sizes, status, and usage metadata.
|
|
487
|
-
The verified
|
|
506
|
+
The verified TEE target and this local proxy can see plaintext.
|
|
488
507
|
`);
|
|
489
508
|
}
|
|
490
509
|
|
|
@@ -0,0 +1,45 @@
|
|
|
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;
|
|
3
|
+
const MISSING_EHBP_RESPONSE_NONCE_PATTERN = /missing\s+ehbp-response-nonce\s+header/i;
|
|
4
|
+
|
|
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.';
|
|
8
|
+
}
|
|
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
|
+
}
|
|
12
|
+
return message || fallback;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function readErrorMessage(response, fallback) {
|
|
16
|
+
let message = fallback;
|
|
17
|
+
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
|
+
}
|
|
35
|
+
} catch {
|
|
36
|
+
try {
|
|
37
|
+
const text = await response.text();
|
|
38
|
+
message = text || fallback;
|
|
39
|
+
} catch {
|
|
40
|
+
message = fallback;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return normalizePrivateModeUpstreamErrorMessage(message, fallback);
|
|
45
|
+
}
|
package/lib/statusContract.js
CHANGED
|
@@ -22,7 +22,7 @@ export const PRIVATE_MODE_FRONTEND_DISABLED_FEATURES = Object.freeze([
|
|
|
22
22
|
export const PRIVATE_MODE_NANOGPT_VISIBLE = Object.freeze([
|
|
23
23
|
'account',
|
|
24
24
|
'selected_private_model',
|
|
25
|
-
'
|
|
25
|
+
'selected_tee_target',
|
|
26
26
|
'request_timing',
|
|
27
27
|
'request_size',
|
|
28
28
|
'response_size',
|
|
@@ -40,7 +40,7 @@ export const PRIVATE_MODE_ENCRYPTED_FROM_NANOGPT = Object.freeze([
|
|
|
40
40
|
|
|
41
41
|
export const PRIVATE_MODE_PLAINTEXT_VISIBLE_TO = Object.freeze([
|
|
42
42
|
'local_proxy',
|
|
43
|
-
'
|
|
43
|
+
'verified_tee_target',
|
|
44
44
|
]);
|
|
45
45
|
|
|
46
46
|
export function buildPrivateModeStatusContract() {
|
|
@@ -4,9 +4,9 @@
|
|
|
4
4
|
"name": "Kimi K2.6 Private",
|
|
5
5
|
"upstreamModel": "kimi-k2-6",
|
|
6
6
|
"billingModel": "TEE/kimi-k2-6",
|
|
7
|
-
"
|
|
7
|
+
"teeTargetModel": "kimi-k2-6",
|
|
8
8
|
"created": 1764547200,
|
|
9
|
-
"ownedBy": "nanogpt-
|
|
9
|
+
"ownedBy": "nanogpt-private-mode",
|
|
10
10
|
"aliases": ["private/kimi-k2.6", "TEE/kimi-k2-6", "TEE/kimi-k2.6"]
|
|
11
11
|
},
|
|
12
12
|
{
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
"name": "GPT OSS 120B Private",
|
|
15
15
|
"upstreamModel": "gpt-oss-120b",
|
|
16
16
|
"billingModel": "TEE/gpt-oss-120b",
|
|
17
|
-
"
|
|
17
|
+
"teeTargetModel": "gpt-oss-120b",
|
|
18
18
|
"created": 1764547200,
|
|
19
|
-
"ownedBy": "nanogpt-
|
|
19
|
+
"ownedBy": "nanogpt-private-mode",
|
|
20
20
|
"aliases": ["TEE/gpt-oss-120b", "phala/gpt-oss-120b"]
|
|
21
21
|
},
|
|
22
22
|
{
|
|
@@ -24,40 +24,30 @@
|
|
|
24
24
|
"name": "Llama 3.3 70B Private",
|
|
25
25
|
"upstreamModel": "llama3-3-70b",
|
|
26
26
|
"billingModel": "TEE/llama3-3-70b",
|
|
27
|
-
"
|
|
27
|
+
"teeTargetModel": "llama3-3-70b",
|
|
28
28
|
"created": 1764547200,
|
|
29
|
-
"ownedBy": "nanogpt-
|
|
29
|
+
"ownedBy": "nanogpt-private-mode",
|
|
30
30
|
"aliases": ["private/llama-3.3-70b", "TEE/llama3-3-70b"]
|
|
31
31
|
},
|
|
32
32
|
{
|
|
33
|
-
"id": "private/glm-5-
|
|
34
|
-
"name": "GLM 5.
|
|
35
|
-
"upstreamModel": "glm-5-
|
|
36
|
-
"billingModel": "TEE/glm-5-
|
|
37
|
-
"
|
|
38
|
-
"created":
|
|
39
|
-
"ownedBy": "nanogpt-
|
|
40
|
-
"aliases": ["private/glm-5.
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
"id": "private/glm-5-1-thinking",
|
|
44
|
-
"name": "GLM 5.1 Thinking Private",
|
|
45
|
-
"upstreamModel": "glm-5-1",
|
|
46
|
-
"billingModel": "TEE/glm-5-1-thinking",
|
|
47
|
-
"tinfoilEnclaveModel": "glm-5-1",
|
|
48
|
-
"created": 1764547200,
|
|
49
|
-
"ownedBy": "nanogpt-tinfoil",
|
|
50
|
-
"aliases": ["private/glm-5.1-thinking", "TEE/glm-5-1-thinking", "TEE/glm-5.1-thinking"]
|
|
33
|
+
"id": "private/glm-5-2",
|
|
34
|
+
"name": "GLM 5.2 Private",
|
|
35
|
+
"upstreamModel": "glm-5-2",
|
|
36
|
+
"billingModel": "TEE/glm-5-2",
|
|
37
|
+
"teeTargetModel": "glm-5-2",
|
|
38
|
+
"created": 1781827200,
|
|
39
|
+
"ownedBy": "nanogpt-private-mode",
|
|
40
|
+
"aliases": ["private/glm-5.2", "TEE/glm-5-2", "TEE/glm-5.2"]
|
|
51
41
|
},
|
|
52
42
|
{
|
|
53
43
|
"id": "private/gemma4-31b",
|
|
54
44
|
"name": "Gemma 4 31B Private",
|
|
55
45
|
"upstreamModel": "gemma4-31b",
|
|
56
46
|
"billingModel": "TEE/gemma4-31b",
|
|
57
|
-
"
|
|
47
|
+
"teeTargetModel": "gemma4-31b",
|
|
58
48
|
"thinkingMode": "gemma",
|
|
59
49
|
"created": 1764547200,
|
|
60
|
-
"ownedBy": "nanogpt-
|
|
50
|
+
"ownedBy": "nanogpt-private-mode",
|
|
61
51
|
"aliases": ["TEE/gemma4-31b", "gemma4-31b"]
|
|
62
52
|
},
|
|
63
53
|
{
|
|
@@ -65,10 +55,10 @@
|
|
|
65
55
|
"name": "Gemma 4 31B Thinking Private",
|
|
66
56
|
"upstreamModel": "gemma4-31b",
|
|
67
57
|
"billingModel": "TEE/gemma4-31b:thinking",
|
|
68
|
-
"
|
|
58
|
+
"teeTargetModel": "gemma4-31b",
|
|
69
59
|
"thinkingMode": "gemma",
|
|
70
60
|
"created": 1764547200,
|
|
71
|
-
"ownedBy": "nanogpt-
|
|
61
|
+
"ownedBy": "nanogpt-private-mode",
|
|
72
62
|
"aliases": ["TEE/gemma4-31b:thinking", "gemma4-31b:thinking"]
|
|
73
63
|
},
|
|
74
64
|
{
|
|
@@ -76,10 +66,10 @@
|
|
|
76
66
|
"name": "DeepSeek V4 Pro Private",
|
|
77
67
|
"upstreamModel": "deepseek-v4-pro",
|
|
78
68
|
"billingModel": "TEE/deepseek-v4-pro",
|
|
79
|
-
"
|
|
69
|
+
"teeTargetModel": "deepseek-v4-pro",
|
|
80
70
|
"thinkingMode": "deepseek-v4-pro",
|
|
81
71
|
"created": 1764547200,
|
|
82
|
-
"ownedBy": "nanogpt-
|
|
72
|
+
"ownedBy": "nanogpt-private-mode",
|
|
83
73
|
"aliases": ["TEE/deepseek-v4-pro", "deepseek-v4-pro"]
|
|
84
74
|
},
|
|
85
75
|
{
|
|
@@ -87,10 +77,10 @@
|
|
|
87
77
|
"name": "DeepSeek V4 Pro Thinking Private",
|
|
88
78
|
"upstreamModel": "deepseek-v4-pro",
|
|
89
79
|
"billingModel": "TEE/deepseek-v4-pro:thinking",
|
|
90
|
-
"
|
|
80
|
+
"teeTargetModel": "deepseek-v4-pro",
|
|
91
81
|
"thinkingMode": "deepseek-v4-pro",
|
|
92
82
|
"created": 1764547200,
|
|
93
|
-
"ownedBy": "nanogpt-
|
|
83
|
+
"ownedBy": "nanogpt-private-mode",
|
|
94
84
|
"aliases": ["TEE/deepseek-v4-pro:thinking", "deepseek-v4-pro:thinking"]
|
|
95
85
|
}
|
|
96
86
|
]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanogpt/private-mode",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "OpenAI-compatible localhost proxy for NanoGPT Private Mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
"lib/originPolicy.js",
|
|
16
16
|
"lib/requestTransforms.js",
|
|
17
17
|
"lib/server.js",
|
|
18
|
+
"lib/serverErrorNormalization.js",
|
|
18
19
|
"lib/statusContract.js",
|
|
19
20
|
"lib/verifyReceipt.js",
|
|
20
21
|
"models",
|