@nanogpt/private-mode 0.1.4 → 0.2.1
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 +4 -0
- package/lib/cacheScope.js +9 -0
- package/lib/originPolicy.js +19 -12
- package/lib/requestTransforms.js +27 -19
- package/lib/secureClientLifecycle.js +126 -0
- package/lib/server.js +74 -60
- package/lib/serverErrorNormalization.js +19 -34
- package/models/private-tee.json +1 -22
- package/package.json +11 -8
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
|
```
|
|
@@ -30,6 +32,8 @@ const response = await client.chat.completions.create({
|
|
|
30
32
|
|
|
31
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.
|
|
32
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.
|
|
36
|
+
|
|
33
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.
|
|
34
38
|
|
|
35
39
|
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.
|
|
@@ -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
|
+
}
|
package/lib/originPolicy.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|
package/lib/requestTransforms.js
CHANGED
|
@@ -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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
225
|
-
delete body.maxCompletionTokens;
|
|
226
|
-
delete body.maxTokens;
|
|
224
|
+
}
|
|
227
225
|
|
|
228
|
-
|
|
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
|
-
|
|
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 === '
|
|
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 {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
const DEFAULT_SECURE_CLIENT_IDLE_RESET_MS = 5 * 60 * 1000;
|
|
2
|
+
|
|
3
|
+
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);
|
|
6
|
+
return DEFAULT_SECURE_CLIENT_IDLE_RESET_MS;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function createSecureState(apiBase, options = {}) {
|
|
10
|
+
const idleResetMs = Number.isFinite(options.idleResetMs) && options.idleResetMs > 0
|
|
11
|
+
? Math.floor(options.idleResetMs)
|
|
12
|
+
: readSecureClientIdleResetMs();
|
|
13
|
+
const loadTinfoil = options.loadTinfoil || (() => import('tinfoil'));
|
|
14
|
+
const now = options.now || Date.now;
|
|
15
|
+
let clientState = null;
|
|
16
|
+
let verificationDocument = null;
|
|
17
|
+
let verificationError = null;
|
|
18
|
+
|
|
19
|
+
async function getClient(userCacheSecret = clientState?.userCacheSecret) {
|
|
20
|
+
if (!userCacheSecret) {
|
|
21
|
+
throw new Error('NanoGPT preflight must establish cache isolation before attestation.');
|
|
22
|
+
}
|
|
23
|
+
const nowMs = now();
|
|
24
|
+
const idleMs = clientState ? nowMs - clientState.lastUsedAtMs : 0;
|
|
25
|
+
if (
|
|
26
|
+
clientState?.userCacheSecret === userCacheSecret
|
|
27
|
+
&& idleMs >= 0
|
|
28
|
+
&& idleMs < idleResetMs
|
|
29
|
+
) {
|
|
30
|
+
clientState.lastUsedAtMs = nowMs;
|
|
31
|
+
return clientState.promise;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Re-attest after a long idle period or a wall-clock discontinuity. This
|
|
35
|
+
// covers laptops resuming after sleep without mutating a client that may
|
|
36
|
+
// still be finishing an older in-flight request.
|
|
37
|
+
verificationDocument = null;
|
|
38
|
+
verificationError = null;
|
|
39
|
+
const nextState = {
|
|
40
|
+
client: null,
|
|
41
|
+
lastUsedAtMs: nowMs,
|
|
42
|
+
promise: null,
|
|
43
|
+
userCacheSecret,
|
|
44
|
+
};
|
|
45
|
+
const promise = Promise.resolve(loadTinfoil()).then(async ({ SecureClient }) => {
|
|
46
|
+
const baseURL = `${apiBase}/api/v1/private/tinfoil/`;
|
|
47
|
+
const client = new SecureClient({
|
|
48
|
+
baseURL,
|
|
49
|
+
attestationBundleURL: `${apiBase}/api/v1/private/tinfoil`,
|
|
50
|
+
transport: 'ehbp',
|
|
51
|
+
userCacheSecret,
|
|
52
|
+
});
|
|
53
|
+
await client.ready();
|
|
54
|
+
nextState.client = client;
|
|
55
|
+
verificationDocument = client.getVerificationDocument();
|
|
56
|
+
verificationError = null;
|
|
57
|
+
return client;
|
|
58
|
+
});
|
|
59
|
+
nextState.promise = promise;
|
|
60
|
+
clientState = nextState;
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
return await promise;
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (clientState === nextState) clientState = null;
|
|
66
|
+
verificationError = error;
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
getClient,
|
|
73
|
+
invalidateClient(client) {
|
|
74
|
+
if (!clientState) return false;
|
|
75
|
+
if (client && clientState.client !== client) return false;
|
|
76
|
+
clientState = null;
|
|
77
|
+
verificationDocument = null;
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
getVerificationState() {
|
|
81
|
+
return {
|
|
82
|
+
verified: verificationDocument?.securityVerified === true,
|
|
83
|
+
verificationDocument,
|
|
84
|
+
error: verificationError ? String(verificationError.message || verificationError) : null,
|
|
85
|
+
};
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function isMissingEncryptedBodyHeaderResponse(response) {
|
|
91
|
+
if (response?.status !== 400) return false;
|
|
92
|
+
try {
|
|
93
|
+
const data = await response.clone().json();
|
|
94
|
+
return data?.error?.code === 'missing_encrypted_body_header';
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function fetchWithSecureClientRecovery({
|
|
101
|
+
fetchWithClient,
|
|
102
|
+
secureState,
|
|
103
|
+
shouldInvalidateError = () => true,
|
|
104
|
+
userCacheSecret,
|
|
105
|
+
}) {
|
|
106
|
+
let client;
|
|
107
|
+
try {
|
|
108
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
109
|
+
client = await secureState.getClient(userCacheSecret);
|
|
110
|
+
const response = await fetchWithClient(client);
|
|
111
|
+
if (attempt === 0 && await isMissingEncryptedBodyHeaderResponse(response)) {
|
|
112
|
+
await response.body?.cancel().catch(() => undefined);
|
|
113
|
+
secureState.invalidateClient(client);
|
|
114
|
+
continue;
|
|
115
|
+
}
|
|
116
|
+
return response;
|
|
117
|
+
}
|
|
118
|
+
} catch (error) {
|
|
119
|
+
if (client && shouldInvalidateError(error)) {
|
|
120
|
+
secureState.invalidateClient(client);
|
|
121
|
+
}
|
|
122
|
+
throw error;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
throw new Error('Private Mode secure-client recovery exhausted unexpectedly.');
|
|
126
|
+
}
|
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,
|
|
@@ -10,6 +11,10 @@ import {
|
|
|
10
11
|
normalizePrivateModeUpstreamErrorMessage,
|
|
11
12
|
readErrorMessage,
|
|
12
13
|
} from './serverErrorNormalization.js';
|
|
14
|
+
import {
|
|
15
|
+
createSecureState,
|
|
16
|
+
fetchWithSecureClientRecovery,
|
|
17
|
+
} from './secureClientLifecycle.js';
|
|
13
18
|
import { buildPrivateModeStatusContract } from './statusContract.js';
|
|
14
19
|
|
|
15
20
|
const MODELS = JSON.parse(
|
|
@@ -142,54 +147,13 @@ function readBody(req) {
|
|
|
142
147
|
|
|
143
148
|
function copyLocalHeaders(req) {
|
|
144
149
|
const headers = {};
|
|
145
|
-
const teamId = req
|
|
150
|
+
const teamId = req?.headers?.['x-team-id'];
|
|
146
151
|
if (typeof teamId === 'string' && teamId.trim()) {
|
|
147
152
|
headers['x-team-id'] = teamId.trim();
|
|
148
153
|
}
|
|
149
154
|
return headers;
|
|
150
155
|
}
|
|
151
156
|
|
|
152
|
-
function createSecureState(apiBase) {
|
|
153
|
-
let clientPromise = null;
|
|
154
|
-
let verificationDocument = null;
|
|
155
|
-
let verificationError = null;
|
|
156
|
-
|
|
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
|
-
});
|
|
177
|
-
}
|
|
178
|
-
return clientPromise;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
return {
|
|
182
|
-
getClient,
|
|
183
|
-
getVerificationState() {
|
|
184
|
-
return {
|
|
185
|
-
verified: verificationDocument?.securityVerified === true,
|
|
186
|
-
verificationDocument,
|
|
187
|
-
error: verificationError ? String(verificationError.message || verificationError) : null,
|
|
188
|
-
};
|
|
189
|
-
},
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
157
|
async function runPreflight({ apiBase, apiKey, model, req, requestBodyBytes }) {
|
|
194
158
|
const preflightBody = { model: model.id };
|
|
195
159
|
if (Number.isSafeInteger(requestBodyBytes) && requestBodyBytes >= 0) {
|
|
@@ -206,7 +170,27 @@ async function runPreflight({ apiBase, apiKey, model, req, requestBodyBytes }) {
|
|
|
206
170
|
body: JSON.stringify(preflightBody),
|
|
207
171
|
});
|
|
208
172
|
|
|
209
|
-
if (response.ok)
|
|
173
|
+
if (response.ok) {
|
|
174
|
+
try {
|
|
175
|
+
const data = await response.json();
|
|
176
|
+
const cacheScope = typeof data?.cacheScope === 'string' ? data.cacheScope.trim() : '';
|
|
177
|
+
if (/^[a-f0-9]{64}$/i.test(cacheScope)) {
|
|
178
|
+
return { ok: true, cacheScope };
|
|
179
|
+
}
|
|
180
|
+
} catch {}
|
|
181
|
+
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
status: 502,
|
|
185
|
+
body: {
|
|
186
|
+
error: {
|
|
187
|
+
message: 'NanoGPT preflight did not establish cache isolation.',
|
|
188
|
+
type: 'api_error',
|
|
189
|
+
code: 'cache_scope_unavailable',
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
}
|
|
210
194
|
|
|
211
195
|
let errorBody = null;
|
|
212
196
|
try {
|
|
@@ -223,6 +207,22 @@ async function runPreflight({ apiBase, apiKey, model, req, requestBodyBytes }) {
|
|
|
223
207
|
return { ok: false, status: response.status, body: errorBody };
|
|
224
208
|
}
|
|
225
209
|
|
|
210
|
+
async function getPreflightedClient({ apiBase, apiKey, secureState, req, model = MODELS[0] }) {
|
|
211
|
+
const preflight = await runPreflight({
|
|
212
|
+
apiBase,
|
|
213
|
+
apiKey,
|
|
214
|
+
model,
|
|
215
|
+
req,
|
|
216
|
+
requestBodyBytes: 0,
|
|
217
|
+
});
|
|
218
|
+
if (!preflight.ok) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
preflight.body?.error?.message || `NanoGPT preflight failed with HTTP ${preflight.status}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
return secureState.getClient(preflight.cacheScope);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
226
|
async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, corsHeaders }) {
|
|
227
227
|
let rawBody;
|
|
228
228
|
try {
|
|
@@ -288,20 +288,25 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
288
288
|
});
|
|
289
289
|
|
|
290
290
|
try {
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
291
|
+
response = await fetchWithSecureClientRecovery({
|
|
292
|
+
secureState,
|
|
293
|
+
userCacheSecret: preflight.cacheScope,
|
|
294
|
+
shouldInvalidateError: () => !upstreamAbortController.signal.aborted,
|
|
295
|
+
fetchWithClient: (client) => client.fetch(`${apiBase}/api/v1/private/tinfoil/v1/chat/completions`, {
|
|
296
|
+
method: 'POST',
|
|
297
|
+
headers: {
|
|
298
|
+
authorization: `Bearer ${apiKey}`,
|
|
299
|
+
'content-type': 'application/json',
|
|
300
|
+
accept: privateStreamRequested ? 'text/event-stream' : 'application/json',
|
|
301
|
+
'x-nanogpt-private-model': model.id,
|
|
302
|
+
'x-nanogpt-private-stream': privateStreamRequested ? 'true' : 'false',
|
|
303
|
+
'x-nanogpt-private-cache-scope': buildPrivateModeCacheScopeProof(preflight.cacheScope),
|
|
304
|
+
'x-query-source': 'api',
|
|
305
|
+
...copyLocalHeaders(req),
|
|
306
|
+
},
|
|
307
|
+
body: privateRequestBody,
|
|
308
|
+
signal: upstreamAbortController.signal,
|
|
309
|
+
}),
|
|
305
310
|
});
|
|
306
311
|
} catch (error) {
|
|
307
312
|
responseComplete = true;
|
|
@@ -309,7 +314,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
|
|
|
309
314
|
const message = error instanceof Error ? error.message : String(error);
|
|
310
315
|
jsonResponse(res, 502, {
|
|
311
316
|
error: {
|
|
312
|
-
message:
|
|
317
|
+
message: normalizePrivateModeUpstreamErrorMessage(message, 'Private Mode request failed.'),
|
|
313
318
|
type: 'api_error',
|
|
314
319
|
code: 'private_mode_request_failed',
|
|
315
320
|
},
|
|
@@ -442,7 +447,12 @@ export async function startPrivateModeProxy(options) {
|
|
|
442
447
|
|
|
443
448
|
if (req.method === 'GET' && url.pathname === '/v1/private-mode/attestation') {
|
|
444
449
|
try {
|
|
445
|
-
await
|
|
450
|
+
await getPreflightedClient({
|
|
451
|
+
apiBase,
|
|
452
|
+
apiKey: options.apiKey,
|
|
453
|
+
secureState,
|
|
454
|
+
req,
|
|
455
|
+
});
|
|
446
456
|
jsonResponse(res, 200, secureState.getVerificationState(), corsHeaders);
|
|
447
457
|
} catch (error) {
|
|
448
458
|
jsonResponse(res, 502, {
|
|
@@ -508,7 +518,11 @@ The verified TEE target and this local proxy can see plaintext.
|
|
|
508
518
|
}
|
|
509
519
|
|
|
510
520
|
if (options.warmAttestation !== false) {
|
|
511
|
-
|
|
521
|
+
getPreflightedClient({
|
|
522
|
+
apiBase,
|
|
523
|
+
apiKey: options.apiKey,
|
|
524
|
+
secureState,
|
|
525
|
+
})
|
|
512
526
|
.then((client) => {
|
|
513
527
|
const doc = client.getVerificationDocument();
|
|
514
528
|
process.stdout.write(`Attestation: verified=${doc.securityVerified === true}; enclave=${doc.enclaveHost || client.getEnclaveURL() || 'unknown'}\n`);
|
|
@@ -1,45 +1,30 @@
|
|
|
1
|
-
// Keep upstream error
|
|
2
|
-
|
|
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
|
|
6
|
-
if (
|
|
7
|
-
return
|
|
7
|
+
export function privateModeProviderFailureMessage(status) {
|
|
8
|
+
if (status === 429) {
|
|
9
|
+
return `Private Mode is temporarily rate-limited. ${PRIVATE_MODE_REFUND_NOTICE}`;
|
|
8
10
|
}
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
19
|
+
return privateModeProviderFailureMessage();
|
|
13
20
|
}
|
|
14
21
|
|
|
15
22
|
export async function readErrorMessage(response, fallback) {
|
|
16
|
-
|
|
23
|
+
void fallback;
|
|
17
24
|
try {
|
|
18
|
-
|
|
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
|
-
|
|
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
|
}
|
package/models/private-tee.json
CHANGED
|
@@ -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
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "OpenAI-compatible localhost proxy for NanoGPT Private Mode.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -12,8 +12,10 @@
|
|
|
12
12
|
},
|
|
13
13
|
"files": [
|
|
14
14
|
"bin",
|
|
15
|
+
"lib/cacheScope.js",
|
|
15
16
|
"lib/originPolicy.js",
|
|
16
17
|
"lib/requestTransforms.js",
|
|
18
|
+
"lib/secureClientLifecycle.js",
|
|
17
19
|
"lib/server.js",
|
|
18
20
|
"lib/serverErrorNormalization.js",
|
|
19
21
|
"lib/statusContract.js",
|
|
@@ -21,14 +23,15 @@
|
|
|
21
23
|
"models",
|
|
22
24
|
"README.md"
|
|
23
25
|
],
|
|
24
|
-
"scripts": {
|
|
25
|
-
"start": "node ./bin/nanogpt-private-mode.js"
|
|
26
|
-
},
|
|
27
26
|
"dependencies": {
|
|
28
|
-
"
|
|
27
|
+
"ai": "6.0.220",
|
|
28
|
+
"tinfoil": "1.1.12"
|
|
29
29
|
},
|
|
30
30
|
"engines": {
|
|
31
|
-
"node": ">=
|
|
31
|
+
"node": ">=22"
|
|
32
32
|
},
|
|
33
|
-
"license": "MIT"
|
|
34
|
-
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"scripts": {
|
|
35
|
+
"start": "node ./bin/nanogpt-private-mode.js"
|
|
36
|
+
}
|
|
37
|
+
}
|