@nanogpt/private-mode 0.2.9 → 0.2.11

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
@@ -5,7 +5,7 @@ OpenAI-compatible localhost proxy for NanoGPT Private Mode with supported TEE mo
5
5
  Requires Node.js 22 or later.
6
6
 
7
7
  ```bash
8
- NANOGPT_API_KEY=sk-your-key npx @nanogpt/private-mode
8
+ NANOGPT_API_KEY=sk-your-key npx --yes @nanogpt/private-mode@latest
9
9
  ```
10
10
 
11
11
  Then point any OpenAI-compatible client at:
@@ -32,6 +32,14 @@ const response = await client.chat.completions.create({
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
+ The model list is bundled with the installed proxy package. Before the proxy starts listening, it checks that list against NanoGPT's hosted Private Mode catalog and stops with an upgrade command if models were added or retired. This prevents an old package from advertising enclave targets that are no longer available. The running proxy reports its exact version at `GET http://127.0.0.1:8787/v1/private-mode/status`. To print the latest published package version, run:
36
+
37
+ ```bash
38
+ npx --yes @nanogpt/private-mode@latest --version
39
+ ```
40
+
41
+ If an older proxy is already running, stop it before starting the command above. API clients must use the localhost base URL shown below; sending `private/*` model IDs directly to NanoGPT's standard `/api/v1/chat/completions` endpoint does not enable Private Mode.
42
+
35
43
  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
44
 
37
45
  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.
@@ -44,6 +52,12 @@ In the hosted web app, decrypted Private Mode turns remain in local browser hist
44
52
 
45
53
  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.
46
54
 
55
+ Because the request body is encrypted before NanoGPT receives it, the service cannot inspect the request's `max_tokens` value during the balance precheck. The default streaming reserve therefore includes up to 32,768 output tokens, even when the encrypted request asks for fewer. This is a temporary reserve, not the final charge: unused balance is refunded when verified usage metadata arrives. The encrypted `max_tokens` value still controls generation inside the verified TEE.
56
+
57
+ Private Mode streaming has a 29-minute soft deadline so billing can settle before the route's 30-minute hard runtime limit. If the decrypted upstream stream ends without both a non-null `finish_reason` and the OpenAI `[DONE]` marker, the local proxy emits an SSE error with code `private_stream_incomplete`. Any text received before that error may be partial.
58
+
59
+ Private Mode currently supports Chat Completions only. The Responses API and Batch API are not available through the encrypted Private Mode endpoint. The standard Responses API outside Private Mode supports background requests within its configured 30-minute runtime limit; work that must reliably continue longer needs a durable job system.
60
+
47
61
  Useful local checks:
48
62
 
49
63
  ```text
@@ -54,17 +68,21 @@ GET http://127.0.0.1:8787/v1/private-mode/attestation
54
68
 
55
69
  Supported private model IDs include:
56
70
 
57
- - `private/deepseek-v4-flash`
71
+ - `private/deepseek-v4-flash` - DeepSeek V4 Flash 0731
58
72
  - `private/kimi-k3`
59
- - `private/glm-5-1`
60
- - `private/glm-5-1-thinking`
73
+ - `private/gpt-oss-120b`
74
+ - `private/llama3-3-70b`
61
75
  - `private/glm-5-2`
62
76
  - `private/glm-5-2:thinking`
77
+ - `private/gemma4-31b`
78
+ - `private/gemma4-31b:thinking`
79
+
80
+ Both GLM 5.2 variants are deployed with a 393,216-token total context limit, with prompt and output sharing that window. Kimi K3 Private has a 256,000-token context limit. DeepSeek V4 Flash 0731 Private has a 1,048,576-token total context limit. The model-list extension `context_length` is the combined prompt-and-output window, while `max_output_tokens` is the output ceiling within that same window. These values are not additive.
63
81
 
64
82
  Browser requests are locked down by default. The proxy only accepts same-machine clients and rejects browser `Origin` headers that are not explicitly allowed, so a random website or LAN client cannot spend the local `NANOGPT_API_KEY` while the proxy is running. If a local browser app needs to call the proxy directly, allow that exact origin:
65
83
 
66
84
  ```bash
67
- NANOGPT_API_KEY=sk-your-key npx @nanogpt/private-mode --allow-origin http://localhost:3000
85
+ NANOGPT_API_KEY=sk-your-key npx --yes @nanogpt/private-mode@latest --allow-origin http://localhost:3000
68
86
  ```
69
87
 
70
88
  You can also set `NANOGPT_PRIVATE_ALLOWED_ORIGINS=http://localhost:3000`. Wildcard browser origins are not supported.
@@ -86,13 +104,13 @@ NanoGPT's hosted web app shows a Private Mode receipt on completed Private Mode
86
104
  To independently verify a copied receipt:
87
105
 
88
106
  ```bash
89
- npx @nanogpt/private-mode verify receipt.json
107
+ npx --yes @nanogpt/private-mode@latest verify receipt.json
90
108
  ```
91
109
 
92
110
  You can also pipe JSON on stdin:
93
111
 
94
112
  ```bash
95
- cat receipt.json | npx @nanogpt/private-mode verify
113
+ cat receipt.json | npx --yes @nanogpt/private-mode@latest verify
96
114
  ```
97
115
 
98
116
  The verifier re-fetches attestation material for the receipt's enclave, verifies it locally, and checks the resulting measurements, release digest, and HPKE key against the copied receipt. If an optional attestation bundle hash is present but the freshly fetched bundle serializes differently, the verifier prints a warning instead of failing the core verification.
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { parseArgs } from 'node:util';
4
+ import { PRIVATE_MODE_PROXY_VERSION } from '../lib/packageInfo.js';
4
5
  import { startPrivateModeProxy } from '../lib/server.js';
5
6
  import { verifyPrivateModeReceiptCli } from '../lib/verifyReceipt.js';
6
7
 
@@ -14,10 +15,16 @@ const { values, positionals } = parseArgs({
14
15
  },
15
16
  'allow-origin': { type: 'string', multiple: true },
16
17
  help: { type: 'boolean', short: 'h', default: false },
18
+ version: { type: 'boolean', short: 'v', default: false },
17
19
  },
18
20
  allowPositionals: true,
19
21
  });
20
22
 
23
+ if (values.version) {
24
+ process.stdout.write(`${PRIVATE_MODE_PROXY_VERSION}\n`);
25
+ process.exit(0);
26
+ }
27
+
21
28
  if (values.help) {
22
29
  process.stdout.write(`NanoGPT Private Mode Proxy
23
30
 
@@ -32,6 +39,7 @@ Options:
32
39
  --api-base <url> NanoGPT API base. Default: https://nano-gpt.com
33
40
  --allow-origin <origin>
34
41
  Browser origin allowed to call the proxy, for example http://localhost:3000
42
+ --version, -v Print the installed proxy version
35
43
  `);
36
44
  process.exit(0);
37
45
  }
@@ -0,0 +1,76 @@
1
+ const DEFAULT_CATALOG_CHECK_TIMEOUT_MS = 10_000;
2
+
3
+ function normalizeModelIds(models) {
4
+ if (!Array.isArray(models)) return null;
5
+
6
+ const ids = models.map((model) => (
7
+ typeof model?.id === 'string' ? model.id.trim().toLowerCase() : ''
8
+ ));
9
+ if (ids.some((id) => !id)) return null;
10
+
11
+ return new Set(ids);
12
+ }
13
+
14
+ export function comparePrivateModeCatalogs(localModels, hostedCatalog) {
15
+ const localIds = normalizeModelIds(localModels);
16
+ const hostedIds = normalizeModelIds(hostedCatalog?.data);
17
+ if (!localIds || !hostedIds) {
18
+ throw new Error('Private Mode model catalog response was malformed.');
19
+ }
20
+
21
+ return {
22
+ status: 'checked',
23
+ retiredLocalModels: [...localIds].filter((id) => !hostedIds.has(id)).sort(),
24
+ missingLocalModels: [...hostedIds].filter((id) => !localIds.has(id)).sort(),
25
+ };
26
+ }
27
+
28
+ export async function checkHostedPrivateModeCatalog({
29
+ apiBase,
30
+ localModels,
31
+ fetchImpl = fetch,
32
+ timeoutMs = DEFAULT_CATALOG_CHECK_TIMEOUT_MS,
33
+ }) {
34
+ const controller = new AbortController();
35
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
36
+
37
+ try {
38
+ const response = await fetchImpl(`${apiBase}/api/v1/private/tinfoil/models`, {
39
+ headers: { accept: 'application/json' },
40
+ signal: controller.signal,
41
+ });
42
+ if (!response.ok) {
43
+ return { status: 'unavailable', reason: `HTTP ${response.status}` };
44
+ }
45
+
46
+ return comparePrivateModeCatalogs(localModels, await response.json());
47
+ } catch (error) {
48
+ return {
49
+ status: 'unavailable',
50
+ reason: error instanceof Error ? error.message : String(error),
51
+ };
52
+ } finally {
53
+ clearTimeout(timeout);
54
+ }
55
+ }
56
+
57
+ export function hasPrivateModeCatalogMismatch(result) {
58
+ return result.status === 'checked'
59
+ && (result.retiredLocalModels.length > 0 || result.missingLocalModels.length > 0);
60
+ }
61
+
62
+ export function buildPrivateModeCatalogUnavailableMessage(result, apiBase) {
63
+ return `Could not verify the bundled Private Mode model catalog against ${apiBase}: ${result.reason}. The proxy did not start because catalog compatibility could not be confirmed. Check your network connection and NanoGPT service availability, then retry.`;
64
+ }
65
+
66
+ export function buildPrivateModeCatalogMismatchMessage(result, proxyVersion) {
67
+ const details = [];
68
+ if (result.retiredLocalModels.length > 0) {
69
+ details.push(`retired local models: ${result.retiredLocalModels.join(', ')}`);
70
+ }
71
+ if (result.missingLocalModels.length > 0) {
72
+ details.push(`hosted models missing locally: ${result.missingLocalModels.join(', ')}`);
73
+ }
74
+
75
+ return `The bundled Private Mode model catalog in @nanogpt/private-mode v${proxyVersion} is out of date (${details.join('; ')}). Stop this proxy and restart with: NANOGPT_API_KEY=... npx --yes @nanogpt/private-mode@latest`;
76
+ }
@@ -0,0 +1,6 @@
1
+ export const PRIVATE_MODE_STREAM_ROUTE_MAX_DURATION_MS = 30 * 60 * 1000;
2
+ export const PRIVATE_MODE_STREAM_SOFT_DEADLINE_LEAD_MS = 60 * 1000;
3
+ export const PRIVATE_MODE_STREAM_SETTLEMENT_SAFETY_MS = 30 * 1000;
4
+ export const PRIVATE_MODE_STREAM_SOFT_DEADLINE_MS =
5
+ PRIVATE_MODE_STREAM_ROUTE_MAX_DURATION_MS - PRIVATE_MODE_STREAM_SOFT_DEADLINE_LEAD_MS;
6
+ export const PRIVATE_MODE_DEFAULT_RESERVE_MAX_OUTPUT_TOKENS = 32_768;
@@ -0,0 +1,8 @@
1
+ import { readFileSync } from 'node:fs';
2
+
3
+ const packageJson = JSON.parse(
4
+ readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
5
+ );
6
+
7
+ export const PRIVATE_MODE_PROXY_VERSION = String(packageJson.version);
8
+ export const PRIVATE_MODE_PROXY_VERSION_HEADER = 'x-nanogpt-private-proxy-version';
@@ -679,13 +679,128 @@ export function createPrivateModeReasoningContentSuppressor() {
679
679
  }
680
680
 
681
681
  export function splitCompleteSseFrames(buffer) {
682
- const parts = buffer.split(/\r?\n\r?\n/);
682
+ const parts = buffer.split(/(?:\r\n|\r|\n){2}/);
683
683
  return {
684
- frames: parts.slice(0, -1).map((frame) => frame.replace(/\r\n/g, '\n')),
684
+ frames: parts.slice(0, -1).map((frame) => frame.replace(/\r\n|\r/g, '\n')),
685
685
  remainder: parts.at(-1) || '',
686
686
  };
687
687
  }
688
688
 
689
+ export function createPrivateModeSseTerminalState(expectedChoiceCount = 1) {
690
+ return {
691
+ expectedChoiceCount: Number.isSafeInteger(expectedChoiceCount) && expectedChoiceCount > 0
692
+ ? expectedChoiceCount
693
+ : 1,
694
+ finishedChoiceIndexes: new Set(),
695
+ sawDone: false,
696
+ };
697
+ }
698
+
699
+ function readPrivateModeSseDataPayload(frame) {
700
+ if (typeof frame !== 'string') return '';
701
+ return frame
702
+ .split('\n')
703
+ .filter((line) => line.startsWith('data:'))
704
+ .map((line) => line.slice('data:'.length).trim())
705
+ .join('\n')
706
+ .trim();
707
+ }
708
+
709
+ export function observePrivateModeSseTerminalFrame(state, frame) {
710
+ if (!isPlainObject(state) || typeof frame !== 'string') return state;
711
+ const payload = readPrivateModeSseDataPayload(frame);
712
+ if (!payload) return state;
713
+ if (payload === '[DONE]') {
714
+ state.sawDone = true;
715
+ return state;
716
+ }
717
+
718
+ try {
719
+ const parsed = JSON.parse(payload);
720
+ if (isPlainObject(parsed) && Array.isArray(parsed.choices)) {
721
+ for (const choice of parsed.choices) {
722
+ if (
723
+ !isPlainObject(choice) ||
724
+ choice.finish_reason === undefined ||
725
+ choice.finish_reason === null
726
+ ) continue;
727
+ const choiceIndex = Number.isSafeInteger(choice.index)
728
+ ? choice.index
729
+ : state.expectedChoiceCount === 1
730
+ ? 0
731
+ : null;
732
+ if (choiceIndex !== null && choiceIndex >= 0 && choiceIndex < state.expectedChoiceCount) {
733
+ state.finishedChoiceIndexes.add(choiceIndex);
734
+ }
735
+ }
736
+ }
737
+ } catch {
738
+ // Other SSE payloads do not contribute to the OpenAI terminal sequence.
739
+ }
740
+ return state;
741
+ }
742
+
743
+ export function isPrivateModeSseTerminalStateComplete(state) {
744
+ return state?.sawDone === true &&
745
+ state?.finishedChoiceIndexes instanceof Set &&
746
+ state.finishedChoiceIndexes.size === state.expectedChoiceCount;
747
+ }
748
+
749
+ export function createPrivateModeSseTerminalGuard(expectedChoiceCount = 1) {
750
+ const state = createPrivateModeSseTerminalState(expectedChoiceCount);
751
+ let emittedDone = false;
752
+ return {
753
+ state,
754
+ observe(frame, forwardedFrame = frame) {
755
+ const doneAlreadyEmitted = emittedDone;
756
+ const containsDone = readPrivateModeSseDataPayload(frame) === '[DONE]';
757
+ observePrivateModeSseTerminalFrame(state, frame);
758
+ // The pump drains transport bytes after client-visible completion so the
759
+ // hosted request can finish settlement. Nothing from that drain belongs
760
+ // on the client side of the canonical terminal marker.
761
+ if (doneAlreadyEmitted) return '';
762
+ // Drop every raw DONE marker so duplicates collapse to one canonical
763
+ // marker. Reasoning suppression may prepend a synthetic visible-content
764
+ // delta, which must remain ahead of that marker.
765
+ const forwardedWithoutDone = containsDone
766
+ ? forwardedFrame
767
+ .split(/(?:\r\n|\r|\n){2}/)
768
+ .filter((forwarded) => readPrivateModeSseDataPayload(forwarded) !== '[DONE]')
769
+ .join('\n\n')
770
+ : forwardedFrame;
771
+
772
+ // A hosted stream can keep transport EOF open while billing settles.
773
+ // Release successful completion as soon as the full SSE terminal
774
+ // sequence is observed, then let the response pump drain the transport.
775
+ if (!emittedDone && isPrivateModeSseTerminalStateComplete(state)) {
776
+ emittedDone = true;
777
+ return [forwardedWithoutDone, 'data: [DONE]'].filter(Boolean).join('\n\n');
778
+ }
779
+ return forwardedWithoutDone;
780
+ },
781
+ hasEmittedDone() {
782
+ return emittedDone;
783
+ },
784
+ finish() {
785
+ if (isPrivateModeSseTerminalStateComplete(state)) {
786
+ if (emittedDone) return { complete: true, frame: '' };
787
+ emittedDone = true;
788
+ return { complete: true, frame: 'data: [DONE]' };
789
+ }
790
+ return {
791
+ complete: false,
792
+ frame: `data: ${JSON.stringify({
793
+ error: {
794
+ message: 'Private Mode stream ended before a complete terminal sequence. The response may be partial; retry the request.',
795
+ type: 'api_error',
796
+ code: 'private_stream_incomplete',
797
+ },
798
+ })}`,
799
+ };
800
+ },
801
+ };
802
+ }
803
+
689
804
  const PRIVATE_MODE_SSE_CHUNK_METADATA_FIELDS = [
690
805
  'id',
691
806
  'object',
package/lib/server.js CHANGED
@@ -2,23 +2,33 @@ import { createServer } from 'node:http';
2
2
  import { readFileSync } from 'node:fs';
3
3
 
4
4
  import { buildPrivateModeCacheScopeProof } from './cacheScope.js';
5
+ import {
6
+ buildPrivateModeCatalogMismatchMessage,
7
+ buildPrivateModeCatalogUnavailableMessage,
8
+ checkHostedPrivateModeCatalog,
9
+ hasPrivateModeCatalogMismatch,
10
+ } from './catalogCompatibility.js';
11
+ import {
12
+ PRIVATE_MODE_DEFAULT_RESERVE_MAX_OUTPUT_TOKENS,
13
+ PRIVATE_MODE_STREAM_SOFT_DEADLINE_MS,
14
+ } from './constants.js';
5
15
  import {
6
16
  buildPrivateModeOriginPolicy,
7
17
  getCorsHeadersForRequest,
8
18
  } from './originPolicy.js';
9
19
  import {
10
20
  applyPrivateModelRequestMutations,
11
- buildPrivateModeSseContentDelta,
12
- createPrivateModeReasoningContentSuppressor,
13
21
  shouldSuppressPrivateModelReasoning,
14
- splitCompleteSseFrames,
15
- suppressPrivateModeReasoningFromSseFrame,
16
22
  suppressPrivateModeReasoningFromJsonText,
17
23
  } from './requestTransforms.js';
18
24
  import {
19
25
  normalizePrivateModeUpstreamErrorMessage,
20
26
  readErrorMessage,
21
27
  } from './serverErrorNormalization.js';
28
+ import {
29
+ PRIVATE_MODE_PROXY_VERSION,
30
+ PRIVATE_MODE_PROXY_VERSION_HEADER,
31
+ } from './packageInfo.js';
22
32
  import {
23
33
  createSecureState,
24
34
  fetchWithSecureClientRecovery,
@@ -27,6 +37,10 @@ import {
27
37
  buildPrivateModeStatusContract,
28
38
  buildPublicAttestationSummary,
29
39
  } from './statusContract.js';
40
+ import {
41
+ isPrivateModeSseResponse,
42
+ pipePrivateModeSseResponse,
43
+ } from './sseResponsePump.js';
30
44
 
31
45
  const MODELS = JSON.parse(
32
46
  readFileSync(new URL('../models/private-tee.json', import.meta.url), 'utf8'),
@@ -79,16 +93,18 @@ function openAIModelList() {
79
93
  object: 'model',
80
94
  created: model.created,
81
95
  owned_by: model.ownedBy,
96
+ ...(model.maxInputTokens ? { context_length: model.maxInputTokens } : {}),
82
97
  ...(model.maxOutputTokens ? { max_output_tokens: model.maxOutputTokens } : {}),
83
98
  })),
84
99
  };
85
100
  }
86
101
 
87
- export function privateModeStatus(apiBase, secureState, localBase, originPolicy) {
102
+ export function privateModeStatus(apiBase, secureState, localBase, originPolicy, catalogCompatibility) {
88
103
  const verificationState = secureState.getVerificationState();
89
104
  return {
90
105
  ok: true,
91
106
  mode: 'private_tee',
107
+ proxy_version: PRIVATE_MODE_PROXY_VERSION,
92
108
  apiBase,
93
109
  local_base_url: localBase,
94
110
  models_path: '/v1/models',
@@ -101,11 +117,18 @@ export function privateModeStatus(apiBase, secureState, localBase, originPolicy)
101
117
  response_body_encrypted: true,
102
118
  streaming: true,
103
119
  streaming_billing: 'precharged_reserve_with_verified_usage_refund',
120
+ // This deadline is enforced by the hosted route, not this process. A
121
+ // local environment override cannot configure the remote service.
122
+ streaming_soft_deadline_ms: PRIVATE_MODE_STREAM_SOFT_DEADLINE_MS,
123
+ default_stream_reserve_max_output_tokens: PRIVATE_MODE_DEFAULT_RESERVE_MAX_OUTPUT_TOKENS,
124
+ responses_api_supported: false,
125
+ batch_api_supported: false,
104
126
  api_local_proxy_required: true,
105
127
  browser_frontend_local_proxy_required: false,
106
128
  ...buildPrivateModeStatusContract(verificationState),
107
129
  browser_origins_allowed: originPolicy.allowedOrigins,
108
130
  models: openAIModelList().data,
131
+ catalog_compatibility: catalogCompatibility,
109
132
  attestation: buildPublicAttestationSummary(verificationState),
110
133
  };
111
134
  }
@@ -178,6 +201,7 @@ async function runPreflight({ apiBase, apiKey, model, req, requestBodyBytes }) {
178
201
  headers: {
179
202
  authorization: `Bearer ${apiKey}`,
180
203
  'content-type': 'application/json',
204
+ [PRIVATE_MODE_PROXY_VERSION_HEADER]: PRIVATE_MODE_PROXY_VERSION,
181
205
  ...copyLocalHeaders(req),
182
206
  },
183
207
  body: JSON.stringify(preflightBody),
@@ -269,7 +293,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
269
293
  if (!model) {
270
294
  jsonResponse(res, 400, {
271
295
  error: {
272
- message: `Unsupported private model "${body.model || ''}".`,
296
+ message: `Unsupported private model "${body.model || ''}" in @nanogpt/private-mode v${PRIVATE_MODE_PROXY_VERSION}. Check this proxy's /v1/models endpoint or restart with: NANOGPT_API_KEY=... npx --yes @nanogpt/private-mode@latest`,
273
297
  type: 'invalid_request_error',
274
298
  code: 'model_not_supported',
275
299
  },
@@ -280,6 +304,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
280
304
  const suppressReasoning = shouldSuppressPrivateModelReasoning(body, model);
281
305
  applyPrivateModelRequestMutations(body, model);
282
306
  const privateStreamRequested = body.stream === true;
307
+ const expectedChoiceCount = Number.isSafeInteger(body.n) && body.n > 0 ? body.n : 1;
283
308
 
284
309
  const privateRequestBody = JSON.stringify(body);
285
310
  const preflight = await runPreflight({
@@ -316,6 +341,7 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
316
341
  'x-nanogpt-private-model': model.id,
317
342
  'x-nanogpt-private-stream': privateStreamRequested ? 'true' : 'false',
318
343
  'x-nanogpt-private-cache-scope': buildPrivateModeCacheScopeProof(preflight.cacheScope),
344
+ [PRIVATE_MODE_PROXY_VERSION_HEADER]: PRIVATE_MODE_PROXY_VERSION,
319
345
  'x-query-source': 'api',
320
346
  ...copyLocalHeaders(req),
321
347
  },
@@ -364,59 +390,30 @@ async function handleChatCompletion({ apiBase, apiKey, secureState, req, res, co
364
390
  return;
365
391
  }
366
392
 
367
- if (privateStreamRequested && response.body) {
393
+ if (isPrivateModeSseResponse(privateStreamRequested, response)) {
368
394
  res.writeHead(response.status, headers);
369
- const reader = response.body.getReader();
370
- const decoder = suppressReasoning ? new TextDecoder() : null;
371
- const contentSuppressor = suppressReasoning
372
- ? createPrivateModeReasoningContentSuppressor()
373
- : null;
374
- const sseChunkMetadata = {};
375
- let sseBuffer = '';
376
- let streamFailed = false;
377
395
  try {
378
- while (true) {
379
- const { done, value } = await reader.read();
380
- if (done) break;
381
- if (res.destroyed) {
382
- await reader.cancel('client disconnected');
383
- upstreamAbortController.abort();
384
- return;
385
- }
386
- if (!value) continue;
387
- if (!suppressReasoning || !decoder || !contentSuppressor) {
388
- res.write(Buffer.from(value));
389
- continue;
390
- }
391
- sseBuffer += decoder.decode(value, { stream: true });
392
- const { frames, remainder } = splitCompleteSseFrames(sseBuffer);
393
- sseBuffer = remainder;
394
- for (const frame of frames) {
395
- res.write(`${suppressPrivateModeReasoningFromSseFrame(frame, contentSuppressor, sseChunkMetadata)}\n\n`);
396
- }
397
- }
398
- if (suppressReasoning && decoder && contentSuppressor) {
399
- sseBuffer += decoder.decode();
400
- if (sseBuffer) {
401
- res.write(`${suppressPrivateModeReasoningFromSseFrame(sseBuffer, contentSuppressor, sseChunkMetadata)}\n\n`);
402
- }
403
- const flushedContent = contentSuppressor.flush();
404
- if (flushedContent) {
405
- const finalDelta = buildPrivateModeSseContentDelta(flushedContent, sseChunkMetadata);
406
- res.write(`data: ${finalDelta}\n\n`);
407
- }
396
+ const outcome = await pipePrivateModeSseResponse({
397
+ body: response.body,
398
+ res,
399
+ suppressReasoning,
400
+ upstreamAbortController,
401
+ expectedChoiceCount,
402
+ });
403
+ // Successful completion and downstream disconnect still count as
404
+ // activity. Truncated or transport-failed streams keep the prior failure
405
+ // behavior and do not refresh the secure-client lifecycle.
406
+ if (!outcome.transportFailed && !outcome.streamFailed) {
407
+ secureState.markClientUsed(responseClient);
408
408
  }
409
409
  } catch (error) {
410
- streamFailed = true;
411
410
  upstreamAbortController.abort();
412
411
  if (!res.destroyed) {
413
- res.destroy(error instanceof Error ? error : new Error(String(error)));
412
+ process.stderr.write(`Private Mode stream pump failed: ${error instanceof Error ? error.message : String(error)}\n`);
414
413
  }
415
- return;
416
414
  } finally {
417
415
  responseComplete = true;
418
- if (!streamFailed) secureState.markClientUsed(responseClient);
419
- if (!streamFailed && !res.writableEnded && !res.destroyed) res.end();
416
+ if (!res.writableEnded && !res.destroyed) res.end();
420
417
  }
421
418
  return;
422
419
  }
@@ -472,6 +469,19 @@ function handleOptions(req, res, corsHeaders) {
472
469
 
473
470
  export async function startPrivateModeProxy(options) {
474
471
  const apiBase = normalizeApiBase(options.apiBase);
472
+ const catalogCompatibility = await checkHostedPrivateModeCatalog({
473
+ apiBase,
474
+ localModels: MODELS,
475
+ });
476
+ if (catalogCompatibility.status === 'unavailable') {
477
+ throw new Error(buildPrivateModeCatalogUnavailableMessage(catalogCompatibility, apiBase));
478
+ }
479
+ if (hasPrivateModeCatalogMismatch(catalogCompatibility)) {
480
+ throw new Error(buildPrivateModeCatalogMismatchMessage(
481
+ catalogCompatibility,
482
+ PRIVATE_MODE_PROXY_VERSION,
483
+ ));
484
+ }
475
485
  const secureState = createSecureState(apiBase);
476
486
  const localBase = `http://${options.host}:${options.port}/v1`;
477
487
  const originPolicy = buildPrivateModeOriginPolicy(options);
@@ -497,7 +507,7 @@ export async function startPrivateModeProxy(options) {
497
507
  }
498
508
 
499
509
  if (req.method === 'GET' && url.pathname === '/health') {
500
- jsonResponse(res, 200, privateModeStatus(apiBase, secureState, localBase, originPolicy), corsHeaders);
510
+ jsonResponse(res, 200, privateModeStatus(apiBase, secureState, localBase, originPolicy, catalogCompatibility), corsHeaders);
501
511
  return;
502
512
  }
503
513
 
@@ -507,7 +517,7 @@ export async function startPrivateModeProxy(options) {
507
517
  }
508
518
 
509
519
  if (req.method === 'GET' && url.pathname === '/v1/private-mode/status') {
510
- jsonResponse(res, 200, privateModeStatus(apiBase, secureState, localBase, originPolicy), corsHeaders);
520
+ jsonResponse(res, 200, privateModeStatus(apiBase, secureState, localBase, originPolicy, catalogCompatibility), corsHeaders);
511
521
  return;
512
522
  }
513
523
 
@@ -543,7 +553,7 @@ export async function startPrivateModeProxy(options) {
543
553
  }
544
554
 
545
555
  if (req.method === 'GET' && url.pathname === '/') {
546
- textResponse(res, 200, `NanoGPT Private Mode Proxy\nOpenAI base URL: ${localBase}\nStatus: /v1/private-mode/status\n`, corsHeaders);
556
+ textResponse(res, 200, `NanoGPT Private Mode Proxy v${PRIVATE_MODE_PROXY_VERSION}\nOpenAI base URL: ${localBase}\nStatus: /v1/private-mode/status\n`, corsHeaders);
547
557
  return;
548
558
  }
549
559
 
@@ -571,6 +581,7 @@ export async function startPrivateModeProxy(options) {
571
581
 
572
582
  if (options.quiet !== true) {
573
583
  process.stdout.write(`NanoGPT Private Mode Proxy
584
+ Version: ${PRIVATE_MODE_PROXY_VERSION}
574
585
  Local base URL: ${localBase}
575
586
  NanoGPT API: ${apiBase}
576
587
  Models: ${MODELS.map((model) => model.id).join(', ')}
@@ -0,0 +1,163 @@
1
+ import {
2
+ buildPrivateModeSseContentDelta,
3
+ createPrivateModeReasoningContentSuppressor,
4
+ createPrivateModeSseTerminalGuard,
5
+ splitCompleteSseFrames,
6
+ suppressPrivateModeReasoningFromSseFrame,
7
+ } from './requestTransforms.js';
8
+
9
+ const MAX_SSE_FRAME_BYTES = 2 * 1024 * 1024;
10
+ const DEFAULT_DOWNSTREAM_DRAIN_TIMEOUT_MS = 60 * 1000;
11
+
12
+ function assertSseFrameWithinLimit(frame) {
13
+ if (Buffer.byteLength(frame, 'utf8') > MAX_SSE_FRAME_BYTES) {
14
+ throw new Error('Private Mode upstream SSE frame exceeded the proxy buffer limit.');
15
+ }
16
+ }
17
+
18
+ export function isPrivateModeSseResponse(streamRequested, response) {
19
+ const contentType = response?.headers?.get?.('content-type') || '';
20
+ return streamRequested === true
21
+ && Boolean(response?.body)
22
+ && contentType.toLowerCase().includes('text/event-stream');
23
+ }
24
+
25
+ function writeWithBackpressure(res, chunk, timeoutMs) {
26
+ if (res.destroyed || res.writableEnded) return Promise.resolve(false);
27
+ try {
28
+ if (res.write(chunk)) return Promise.resolve(true);
29
+ } catch {
30
+ return Promise.resolve(false);
31
+ }
32
+
33
+ return new Promise((resolve) => {
34
+ let drainTimer;
35
+ const cleanup = () => {
36
+ if (drainTimer) clearTimeout(drainTimer);
37
+ res.off('drain', onDrain);
38
+ res.off('close', onClose);
39
+ res.off('error', onError);
40
+ };
41
+ const settle = (writable) => {
42
+ cleanup();
43
+ resolve(writable);
44
+ };
45
+ const onDrain = () => settle(!res.destroyed && !res.writableEnded);
46
+ const onClose = () => settle(false);
47
+ const onError = () => settle(false);
48
+ res.once('drain', onDrain);
49
+ res.once('close', onClose);
50
+ res.once('error', onError);
51
+ drainTimer = setTimeout(() => settle(false), timeoutMs);
52
+ drainTimer.unref?.();
53
+ });
54
+ }
55
+
56
+ export async function pipePrivateModeSseResponse({
57
+ body,
58
+ res,
59
+ suppressReasoning,
60
+ upstreamAbortController,
61
+ expectedChoiceCount = 1,
62
+ downstreamDrainTimeoutMs = DEFAULT_DOWNSTREAM_DRAIN_TIMEOUT_MS,
63
+ }) {
64
+ let reader;
65
+ const decoder = new TextDecoder();
66
+ const contentSuppressor = suppressReasoning
67
+ ? createPrivateModeReasoningContentSuppressor()
68
+ : null;
69
+ const terminalGuard = createPrivateModeSseTerminalGuard(expectedChoiceCount);
70
+ const sseChunkMetadata = {};
71
+ let sseBuffer = '';
72
+ let transportFailed = false;
73
+
74
+ const writeFrame = async (frame) => {
75
+ if (!frame) return true;
76
+ return writeWithBackpressure(res, `${frame}\n\n`, downstreamDrainTimeoutMs);
77
+ };
78
+ const forwardFrame = async (frame) => {
79
+ const forwarded = suppressReasoning && contentSuppressor
80
+ ? suppressPrivateModeReasoningFromSseFrame(frame, contentSuppressor, sseChunkMetadata)
81
+ : frame;
82
+ return writeFrame(terminalGuard.observe(frame, forwarded));
83
+ };
84
+ const finishStream = async () => {
85
+ // A complete terminal sequence may be released before transport EOF.
86
+ // Never append a defensive suppressor flush after that marker.
87
+ if (contentSuppressor && !terminalGuard.hasEmittedDone()) {
88
+ const flushedContent = contentSuppressor.flush();
89
+ if (flushedContent) {
90
+ const delta = buildPrivateModeSseContentDelta(flushedContent, sseChunkMetadata);
91
+ if (!await writeFrame(`data: ${delta}`)) return null;
92
+ }
93
+ }
94
+ const terminal = terminalGuard.finish();
95
+ if (!await writeFrame(terminal.frame)) return null;
96
+ return terminal;
97
+ };
98
+
99
+ try {
100
+ reader = body.getReader();
101
+ while (true) {
102
+ const { done, value } = await reader.read();
103
+ if (done) break;
104
+ if (res.destroyed || res.writableEnded) {
105
+ await reader.cancel('client disconnected').catch(() => {});
106
+ upstreamAbortController.abort();
107
+ return { streamFailed: false, clientDisconnected: true, transportFailed: false };
108
+ }
109
+ if (!value) continue;
110
+ sseBuffer += decoder.decode(value, { stream: true });
111
+ const { frames, remainder } = splitCompleteSseFrames(sseBuffer);
112
+ sseBuffer = remainder;
113
+ assertSseFrameWithinLimit(sseBuffer);
114
+ for (const frame of frames) {
115
+ assertSseFrameWithinLimit(frame);
116
+ if (!await forwardFrame(frame)) {
117
+ await reader.cancel('client disconnected').catch(() => {});
118
+ upstreamAbortController.abort();
119
+ return { streamFailed: false, clientDisconnected: true, transportFailed: false };
120
+ }
121
+ }
122
+ }
123
+ sseBuffer += decoder.decode();
124
+ if (sseBuffer && !await forwardFrame(sseBuffer)) {
125
+ await reader.cancel('client disconnected').catch(() => {});
126
+ upstreamAbortController.abort();
127
+ return { streamFailed: false, clientDisconnected: true, transportFailed: false };
128
+ }
129
+ } catch (error) {
130
+ transportFailed = true;
131
+ upstreamAbortController.abort();
132
+ process.stderr.write(`Private Mode upstream stream failed: ${error instanceof Error ? error.message : String(error)}\n`);
133
+ }
134
+
135
+ if (res.destroyed || res.writableEnded) {
136
+ upstreamAbortController.abort();
137
+ return { streamFailed: false, clientDisconnected: true, transportFailed };
138
+ }
139
+
140
+ let terminal;
141
+ try {
142
+ terminal = await finishStream();
143
+ } catch (error) {
144
+ transportFailed = true;
145
+ upstreamAbortController.abort();
146
+ process.stderr.write(`Private Mode stream finalization failed: ${error instanceof Error ? error.message : String(error)}\n`);
147
+ if (!terminalGuard.hasEmittedDone()) {
148
+ await writeFrame(`data: ${JSON.stringify({
149
+ error: {
150
+ message: 'Private Mode stream ended before a complete terminal sequence. The response may be partial; retry the request.',
151
+ type: 'api_error',
152
+ code: 'private_stream_incomplete',
153
+ },
154
+ })}`);
155
+ }
156
+ return { streamFailed: true, clientDisconnected: false, transportFailed };
157
+ }
158
+ if (!terminal) {
159
+ upstreamAbortController.abort();
160
+ return { streamFailed: false, clientDisconnected: true, transportFailed };
161
+ }
162
+ return { streamFailed: !terminal.complete, clientDisconnected: false, transportFailed };
163
+ }
@@ -1,12 +1,13 @@
1
1
  [
2
2
  {
3
3
  "id": "private/deepseek-v4-flash",
4
- "name": "DeepSeek V4 Flash Private",
4
+ "name": "DeepSeek V4 Flash 0731 Private",
5
5
  "upstreamModel": "deepseek-v4-flash",
6
6
  "billingModel": "private/deepseek-v4-flash",
7
7
  "providerPricingModel": "TEE/deepseek-v4-flash",
8
8
  "teeTargetModel": "deepseek-v4-flash",
9
9
  "thinkingMode": "deepseek-v4",
10
+ "maxInputTokens": 1048576,
10
11
  "maxOutputTokens": 1048576,
11
12
  "created": 1786406400,
12
13
  "ownedBy": "nanogpt-private-mode",
@@ -55,6 +56,8 @@
55
56
  "providerPricingModel": "TEE/glm-5.2",
56
57
  "teeTargetModel": "glm-5-2",
57
58
  "thinkingMode": "glm-5.2",
59
+ "maxInputTokens": 393216,
60
+ "maxOutputTokens": 131072,
58
61
  "created": 1781827200,
59
62
  "ownedBy": "nanogpt-private-mode",
60
63
  "aliases": ["private/glm-5.2", "TEE/glm-5-2", "TEE/glm-5.2"]
@@ -67,6 +70,8 @@
67
70
  "providerPricingModel": "TEE/glm-5.2:thinking",
68
71
  "teeTargetModel": "glm-5-2",
69
72
  "thinkingMode": "glm-5.2",
73
+ "maxInputTokens": 393216,
74
+ "maxOutputTokens": 131072,
70
75
  "created": 1781827200,
71
76
  "ownedBy": "nanogpt-private-mode",
72
77
  "aliases": ["private/glm-5.2:thinking", "TEE/glm-5-2:thinking", "TEE/glm-5.2:thinking"]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanogpt/private-mode",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "OpenAI-compatible localhost proxy for NanoGPT Private Mode.",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -13,11 +13,15 @@
13
13
  "files": [
14
14
  "bin",
15
15
  "lib/cacheScope.js",
16
+ "lib/catalogCompatibility.js",
17
+ "lib/constants.js",
16
18
  "lib/originPolicy.js",
19
+ "lib/packageInfo.js",
17
20
  "lib/requestTransforms.js",
18
21
  "lib/secureClientLifecycle.js",
19
22
  "lib/server.js",
20
23
  "lib/serverErrorNormalization.js",
24
+ "lib/sseResponsePump.js",
21
25
  "lib/statusContract.js",
22
26
  "lib/verifyReceipt.js",
23
27
  "models",