@nanogpt/private-mode 0.2.9 → 0.2.13
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 +28 -8
- package/bin/nanogpt-private-mode.js +8 -0
- package/lib/catalogCompatibility.js +76 -0
- package/lib/constants.js +6 -0
- package/lib/packageInfo.js +8 -0
- package/lib/requestTransforms.js +159 -13
- package/lib/server.js +64 -53
- package/lib/serverErrorNormalization.js +1 -1
- package/lib/sseResponsePump.js +163 -0
- package/lib/statusContract.js +2 -1
- package/models/private-tee.json +34 -1
- package/package.json +5 -1
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,18 +32,32 @@ 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.
|
|
38
46
|
|
|
39
47
|
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.
|
|
40
48
|
|
|
41
|
-
The web
|
|
49
|
+
The web app supports local image attachments for Private Mode vision models (Kimi K3 and Gemma 4 31B). Image bytes are converted to base64 in the browser and included inside the EHBP-encrypted request; they are not uploaded to NanoGPT object storage. Other attachment types, web search, URL-scraped content, project tools, multi-model chat, Context Memory injection, quick replies, and automatic title generation remain disabled for private turns.
|
|
42
50
|
|
|
43
51
|
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.
|
|
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,23 @@ 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/
|
|
60
|
-
- `private/
|
|
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/glm-5-3`
|
|
78
|
+
- `private/glm-5-3-flash`
|
|
79
|
+
- `private/gemma4-31b`
|
|
80
|
+
- `private/gemma4-31b:thinking`
|
|
81
|
+
|
|
82
|
+
Both GLM 5.2 variants are deployed with a 393,216-token total context limit, with prompt and output sharing that window. GLM 5.3 Private and GLM 5.3 Flash Private each have a 1,048,576-token total context limit and a 131,072-token output ceiling. 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
83
|
|
|
64
84
|
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
85
|
|
|
66
86
|
```bash
|
|
67
|
-
NANOGPT_API_KEY=sk-your-key npx @nanogpt/private-mode --allow-origin http://localhost:3000
|
|
87
|
+
NANOGPT_API_KEY=sk-your-key npx --yes @nanogpt/private-mode@latest --allow-origin http://localhost:3000
|
|
68
88
|
```
|
|
69
89
|
|
|
70
90
|
You can also set `NANOGPT_PRIVATE_ALLOWED_ORIGINS=http://localhost:3000`. Wildcard browser origins are not supported.
|
|
@@ -86,13 +106,13 @@ NanoGPT's hosted web app shows a Private Mode receipt on completed Private Mode
|
|
|
86
106
|
To independently verify a copied receipt:
|
|
87
107
|
|
|
88
108
|
```bash
|
|
89
|
-
npx @nanogpt/private-mode verify receipt.json
|
|
109
|
+
npx --yes @nanogpt/private-mode@latest verify receipt.json
|
|
90
110
|
```
|
|
91
111
|
|
|
92
112
|
You can also pipe JSON on stdin:
|
|
93
113
|
|
|
94
114
|
```bash
|
|
95
|
-
cat receipt.json | npx @nanogpt/private-mode verify
|
|
115
|
+
cat receipt.json | npx --yes @nanogpt/private-mode@latest verify
|
|
96
116
|
```
|
|
97
117
|
|
|
98
118
|
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
|
+
}
|
package/lib/constants.js
ADDED
|
@@ -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';
|
package/lib/requestTransforms.js
CHANGED
|
@@ -29,6 +29,7 @@ const KIMI_K3_TEXT_ENCODER = new TextEncoder();
|
|
|
29
29
|
const DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS = 1_048_576;
|
|
30
30
|
const DEEPSEEK_V4_MAX_COMPLETION_TOKENS = 1_048_576;
|
|
31
31
|
const DEEPSEEK_V4_CONTEXT_SAFETY_MARGIN_TOKENS = 10;
|
|
32
|
+
const GLM_53_MAX_COMPLETION_TOKENS = 131_072;
|
|
32
33
|
const PRIVATE_TINFOIL_CHAT_COMPLETION_BODY_FIELDS = new Set([
|
|
33
34
|
'chat_template_kwargs',
|
|
34
35
|
'frequency_penalty',
|
|
@@ -243,6 +244,14 @@ function normalizeDeepSeekV4ReasoningEffort(value) {
|
|
|
243
244
|
return 'high';
|
|
244
245
|
}
|
|
245
246
|
|
|
247
|
+
function normalizeGlm53ReasoningEffort(value) {
|
|
248
|
+
const normalized = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
249
|
+
if (normalized === 'max' || normalized === 'xhigh') return 'max';
|
|
250
|
+
if (normalized === 'low' || normalized === 'minimal') return 'low';
|
|
251
|
+
if (normalized === 'high' || normalized === 'medium') return 'high';
|
|
252
|
+
return 'max';
|
|
253
|
+
}
|
|
254
|
+
|
|
246
255
|
function getPrivateModeModelSignals(body, model) {
|
|
247
256
|
return [
|
|
248
257
|
body.model,
|
|
@@ -419,6 +428,10 @@ function isPrivateModeKimiK3Model(model) {
|
|
|
419
428
|
});
|
|
420
429
|
}
|
|
421
430
|
|
|
431
|
+
function isPrivateModeGlm53Model(model) {
|
|
432
|
+
return model.thinkingMode === 'glm-5.3';
|
|
433
|
+
}
|
|
434
|
+
|
|
422
435
|
function getPrivateModeFunctionToolName(tool) {
|
|
423
436
|
if (!isPlainObject(tool) || tool.type !== 'function' || !isPlainObject(tool.function)) {
|
|
424
437
|
return undefined;
|
|
@@ -513,7 +526,7 @@ export function normalizePrivateModelReasoningControls(body, model) {
|
|
|
513
526
|
|
|
514
527
|
export function shouldSuppressPrivateModelReasoning(body, model) {
|
|
515
528
|
const isKimiK3 = isPrivateModeKimiK3Model(model);
|
|
516
|
-
if (!isKimiK3 && model.thinkingMode !== 'deepseek-v4') return false;
|
|
529
|
+
if (!isKimiK3 && model.thinkingMode !== 'deepseek-v4' && model.thinkingMode !== 'glm-5.3') return false;
|
|
517
530
|
const reasoningVisibilityOptOut = body.reasoningOptOut === true ||
|
|
518
531
|
body.exposeReasoning === false ||
|
|
519
532
|
body.materializeReasoning === false ||
|
|
@@ -528,9 +541,6 @@ export function shouldSuppressPrivateModelReasoning(body, model) {
|
|
|
528
541
|
|
|
529
542
|
function resolvePrivateModeKimiK3ReasoningEffort(body) {
|
|
530
543
|
const candidates = [
|
|
531
|
-
body.reasoningOptOut === true ? false : undefined,
|
|
532
|
-
body.exposeReasoning === false ? false : undefined,
|
|
533
|
-
body.materializeReasoning === false ? false : undefined,
|
|
534
544
|
body.enable_thinking,
|
|
535
545
|
body.thinking,
|
|
536
546
|
body.reasoning_effort,
|
|
@@ -544,8 +554,7 @@ function resolvePrivateModeKimiK3ReasoningEffort(body) {
|
|
|
544
554
|
const normalizedEffort = typeof explicitEffort === 'string'
|
|
545
555
|
? explicitEffort.trim().toLowerCase()
|
|
546
556
|
: undefined;
|
|
547
|
-
|
|
548
|
-
return excludesReasoning ? normalizedEffort ?? 'low' : normalizedEffort ?? 'max';
|
|
557
|
+
return normalizedEffort ?? 'max';
|
|
549
558
|
}
|
|
550
559
|
|
|
551
560
|
function applyPrivateModeKimiK3RequestParams(body, model) {
|
|
@@ -618,6 +627,20 @@ function clampPrivateModeDeepSeekV4Output(body) {
|
|
|
618
627
|
);
|
|
619
628
|
}
|
|
620
629
|
|
|
630
|
+
function clampPrivateModeGlm53Output(body) {
|
|
631
|
+
const requested = typeof body.max_tokens === 'number' && Number.isFinite(body.max_tokens)
|
|
632
|
+
? body.max_tokens
|
|
633
|
+
: undefined;
|
|
634
|
+
if (requested === undefined) return;
|
|
635
|
+
const promptTokens = estimatePrivateModeKimiK3PromptTokens(body);
|
|
636
|
+
const remainingContext = Math.max(1, DEEPSEEK_V4_CONTEXT_WINDOW_TOKENS - promptTokens - DEEPSEEK_V4_CONTEXT_SAFETY_MARGIN_TOKENS);
|
|
637
|
+
body.max_tokens = Math.min(
|
|
638
|
+
requested < 0 ? GLM_53_MAX_COMPLETION_TOKENS : Math.max(1, Math.floor(requested)),
|
|
639
|
+
GLM_53_MAX_COMPLETION_TOKENS,
|
|
640
|
+
remainingContext,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
621
644
|
export function createPrivateModeReasoningContentSuppressor() {
|
|
622
645
|
const openTags = ['<think>', '<thinking>', '<previous_reasoning>', '◁think▷'];
|
|
623
646
|
const closeTags = ['</think>', '</thinking>', '</previous_reasoning>', '◁/think▷'];
|
|
@@ -679,13 +702,128 @@ export function createPrivateModeReasoningContentSuppressor() {
|
|
|
679
702
|
}
|
|
680
703
|
|
|
681
704
|
export function splitCompleteSseFrames(buffer) {
|
|
682
|
-
const parts = buffer.split(
|
|
705
|
+
const parts = buffer.split(/(?:\r\n|\r|\n){2}/);
|
|
683
706
|
return {
|
|
684
|
-
frames: parts.slice(0, -1).map((frame) => frame.replace(/\r\n/g, '\n')),
|
|
707
|
+
frames: parts.slice(0, -1).map((frame) => frame.replace(/\r\n|\r/g, '\n')),
|
|
685
708
|
remainder: parts.at(-1) || '',
|
|
686
709
|
};
|
|
687
710
|
}
|
|
688
711
|
|
|
712
|
+
export function createPrivateModeSseTerminalState(expectedChoiceCount = 1) {
|
|
713
|
+
return {
|
|
714
|
+
expectedChoiceCount: Number.isSafeInteger(expectedChoiceCount) && expectedChoiceCount > 0
|
|
715
|
+
? expectedChoiceCount
|
|
716
|
+
: 1,
|
|
717
|
+
finishedChoiceIndexes: new Set(),
|
|
718
|
+
sawDone: false,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function readPrivateModeSseDataPayload(frame) {
|
|
723
|
+
if (typeof frame !== 'string') return '';
|
|
724
|
+
return frame
|
|
725
|
+
.split('\n')
|
|
726
|
+
.filter((line) => line.startsWith('data:'))
|
|
727
|
+
.map((line) => line.slice('data:'.length).trim())
|
|
728
|
+
.join('\n')
|
|
729
|
+
.trim();
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
export function observePrivateModeSseTerminalFrame(state, frame) {
|
|
733
|
+
if (!isPlainObject(state) || typeof frame !== 'string') return state;
|
|
734
|
+
const payload = readPrivateModeSseDataPayload(frame);
|
|
735
|
+
if (!payload) return state;
|
|
736
|
+
if (payload === '[DONE]') {
|
|
737
|
+
state.sawDone = true;
|
|
738
|
+
return state;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
try {
|
|
742
|
+
const parsed = JSON.parse(payload);
|
|
743
|
+
if (isPlainObject(parsed) && Array.isArray(parsed.choices)) {
|
|
744
|
+
for (const choice of parsed.choices) {
|
|
745
|
+
if (
|
|
746
|
+
!isPlainObject(choice) ||
|
|
747
|
+
choice.finish_reason === undefined ||
|
|
748
|
+
choice.finish_reason === null
|
|
749
|
+
) continue;
|
|
750
|
+
const choiceIndex = Number.isSafeInteger(choice.index)
|
|
751
|
+
? choice.index
|
|
752
|
+
: state.expectedChoiceCount === 1
|
|
753
|
+
? 0
|
|
754
|
+
: null;
|
|
755
|
+
if (choiceIndex !== null && choiceIndex >= 0 && choiceIndex < state.expectedChoiceCount) {
|
|
756
|
+
state.finishedChoiceIndexes.add(choiceIndex);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
} catch {
|
|
761
|
+
// Other SSE payloads do not contribute to the OpenAI terminal sequence.
|
|
762
|
+
}
|
|
763
|
+
return state;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
export function isPrivateModeSseTerminalStateComplete(state) {
|
|
767
|
+
return state?.sawDone === true &&
|
|
768
|
+
state?.finishedChoiceIndexes instanceof Set &&
|
|
769
|
+
state.finishedChoiceIndexes.size === state.expectedChoiceCount;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
export function createPrivateModeSseTerminalGuard(expectedChoiceCount = 1) {
|
|
773
|
+
const state = createPrivateModeSseTerminalState(expectedChoiceCount);
|
|
774
|
+
let emittedDone = false;
|
|
775
|
+
return {
|
|
776
|
+
state,
|
|
777
|
+
observe(frame, forwardedFrame = frame) {
|
|
778
|
+
const doneAlreadyEmitted = emittedDone;
|
|
779
|
+
const containsDone = readPrivateModeSseDataPayload(frame) === '[DONE]';
|
|
780
|
+
observePrivateModeSseTerminalFrame(state, frame);
|
|
781
|
+
// The pump drains transport bytes after client-visible completion so the
|
|
782
|
+
// hosted request can finish settlement. Nothing from that drain belongs
|
|
783
|
+
// on the client side of the canonical terminal marker.
|
|
784
|
+
if (doneAlreadyEmitted) return '';
|
|
785
|
+
// Drop every raw DONE marker so duplicates collapse to one canonical
|
|
786
|
+
// marker. Reasoning suppression may prepend a synthetic visible-content
|
|
787
|
+
// delta, which must remain ahead of that marker.
|
|
788
|
+
const forwardedWithoutDone = containsDone
|
|
789
|
+
? forwardedFrame
|
|
790
|
+
.split(/(?:\r\n|\r|\n){2}/)
|
|
791
|
+
.filter((forwarded) => readPrivateModeSseDataPayload(forwarded) !== '[DONE]')
|
|
792
|
+
.join('\n\n')
|
|
793
|
+
: forwardedFrame;
|
|
794
|
+
|
|
795
|
+
// A hosted stream can keep transport EOF open while billing settles.
|
|
796
|
+
// Release successful completion as soon as the full SSE terminal
|
|
797
|
+
// sequence is observed, then let the response pump drain the transport.
|
|
798
|
+
if (!emittedDone && isPrivateModeSseTerminalStateComplete(state)) {
|
|
799
|
+
emittedDone = true;
|
|
800
|
+
return [forwardedWithoutDone, 'data: [DONE]'].filter(Boolean).join('\n\n');
|
|
801
|
+
}
|
|
802
|
+
return forwardedWithoutDone;
|
|
803
|
+
},
|
|
804
|
+
hasEmittedDone() {
|
|
805
|
+
return emittedDone;
|
|
806
|
+
},
|
|
807
|
+
finish() {
|
|
808
|
+
if (isPrivateModeSseTerminalStateComplete(state)) {
|
|
809
|
+
if (emittedDone) return { complete: true, frame: '' };
|
|
810
|
+
emittedDone = true;
|
|
811
|
+
return { complete: true, frame: 'data: [DONE]' };
|
|
812
|
+
}
|
|
813
|
+
return {
|
|
814
|
+
complete: false,
|
|
815
|
+
frame: `data: ${JSON.stringify({
|
|
816
|
+
error: {
|
|
817
|
+
message: 'Private Mode stream ended before a complete terminal sequence. The response may be partial; retry the request.',
|
|
818
|
+
type: 'api_error',
|
|
819
|
+
code: 'private_stream_incomplete',
|
|
820
|
+
},
|
|
821
|
+
})}`,
|
|
822
|
+
};
|
|
823
|
+
},
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
689
827
|
const PRIVATE_MODE_SSE_CHUNK_METADATA_FIELDS = [
|
|
690
828
|
'id',
|
|
691
829
|
'object',
|
|
@@ -896,10 +1034,12 @@ function normalizeStreamOptions(body) {
|
|
|
896
1034
|
}
|
|
897
1035
|
|
|
898
1036
|
export function applyPrivateModelRequestMutations(body, model) {
|
|
899
|
-
if (isPrivateModeKimiK3Model(model)) {
|
|
1037
|
+
if (isPrivateModeKimiK3Model(model) || isPrivateModeGlm53Model(model)) {
|
|
900
1038
|
normalizePrivateModelReasoningControls(body, model);
|
|
901
1039
|
normalizePrivateModeKimiK3ReasoningFromMessages(body);
|
|
902
|
-
|
|
1040
|
+
if (isPrivateModeKimiK3Model(model)) {
|
|
1041
|
+
normalizePrivateModeKimiK3DynamicToolMessages(body);
|
|
1042
|
+
}
|
|
903
1043
|
} else {
|
|
904
1044
|
stripPrivateModeReasoningFromMessages(body);
|
|
905
1045
|
}
|
|
@@ -911,6 +1051,8 @@ export function applyPrivateModelRequestMutations(body, model) {
|
|
|
911
1051
|
applyPrivateModeKimiK3RequestParams(body, model);
|
|
912
1052
|
} else if (model.thinkingMode === 'deepseek-v4') {
|
|
913
1053
|
clampPrivateModeDeepSeekV4Output(body);
|
|
1054
|
+
} else if (model.thinkingMode === 'glm-5.3') {
|
|
1055
|
+
clampPrivateModeGlm53Output(body);
|
|
914
1056
|
}
|
|
915
1057
|
|
|
916
1058
|
if (model.thinkingMode === 'gemma') {
|
|
@@ -922,9 +1064,12 @@ export function applyPrivateModelRequestMutations(body, model) {
|
|
|
922
1064
|
delete body.reasoning_effort;
|
|
923
1065
|
} else if (
|
|
924
1066
|
model.thinkingMode === 'glm-5.2' ||
|
|
1067
|
+
model.thinkingMode === 'glm-5.3' ||
|
|
925
1068
|
model.thinkingMode === 'deepseek-v4'
|
|
926
1069
|
) {
|
|
927
|
-
const thinkingEnabled =
|
|
1070
|
+
const thinkingEnabled = model.thinkingMode === 'glm-5.3'
|
|
1071
|
+
? true
|
|
1072
|
+
: shouldEnableThinking(body, model);
|
|
928
1073
|
const requestedReasoningEffort = body.reasoning_effort
|
|
929
1074
|
?? (isPlainObject(body.reasoning) ? body.reasoning.effort : undefined);
|
|
930
1075
|
body.chat_template_kwargs = {
|
|
@@ -933,8 +1078,9 @@ export function applyPrivateModelRequestMutations(body, model) {
|
|
|
933
1078
|
};
|
|
934
1079
|
|
|
935
1080
|
if (thinkingEnabled) {
|
|
936
|
-
body.chat_template_kwargs.reasoning_effort =
|
|
937
|
-
|
|
1081
|
+
body.chat_template_kwargs.reasoning_effort = model.thinkingMode === 'glm-5.3'
|
|
1082
|
+
? normalizeGlm53ReasoningEffort(requestedReasoningEffort)
|
|
1083
|
+
: normalizeDeepSeekV4ReasoningEffort(requestedReasoningEffort);
|
|
938
1084
|
} else {
|
|
939
1085
|
delete body.chat_template_kwargs.reasoning_effort;
|
|
940
1086
|
}
|
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
|
|
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
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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 (!
|
|
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(', ')}
|
|
@@ -6,7 +6,7 @@ const EHBP_RESPONSE_NONCE_HEADER = 'ehbp-response-nonce';
|
|
|
6
6
|
const PRIVATE_MODE_REFUND_NOTICE = 'Any reserved balance will be released or refunded.';
|
|
7
7
|
const MAX_UPSTREAM_ERROR_BODY_BYTES = 16 * 1024;
|
|
8
8
|
const MAX_UPSTREAM_ERROR_MESSAGE_CHARS = 1_000;
|
|
9
|
-
const HIDDEN_PROVIDER_NAME_PATTERN = /\b(?:openrouter|spoke\s*ai|aihubmix|
|
|
9
|
+
const HIDDEN_PROVIDER_NAME_PATTERN = /\b(?:openrouter|spoke\s*ai|aihubmix|mimas|comet|azure|digital\s*ocean|axionic|whale\s*ai|langfork)\b/gi;
|
|
10
10
|
|
|
11
11
|
export function privateModeProviderFailureMessage(status) {
|
|
12
12
|
if (status === 429) {
|
|
@@ -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
|
+
}
|
package/lib/statusContract.js
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
|
|
|
2
2
|
|
|
3
3
|
export const PRIVATE_MODE_FRONTEND_SUPPORTED_FEATURES = Object.freeze([
|
|
4
4
|
'text_chat',
|
|
5
|
+
'vision_image_inputs',
|
|
5
6
|
'streaming',
|
|
6
7
|
'conversation_history',
|
|
7
8
|
'model_settings',
|
|
@@ -9,7 +10,7 @@ export const PRIVATE_MODE_FRONTEND_SUPPORTED_FEATURES = Object.freeze([
|
|
|
9
10
|
]);
|
|
10
11
|
|
|
11
12
|
export const PRIVATE_MODE_FRONTEND_DISABLED_FEATURES = Object.freeze([
|
|
12
|
-
'
|
|
13
|
+
'non_image_attachments',
|
|
13
14
|
'web_search',
|
|
14
15
|
'url_scraped_content',
|
|
15
16
|
'project_chats',
|
package/models/private-tee.json
CHANGED
|
@@ -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,10 +70,40 @@
|
|
|
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"]
|
|
73
78
|
},
|
|
79
|
+
{
|
|
80
|
+
"id": "private/glm-5-3",
|
|
81
|
+
"name": "GLM 5.3 Private",
|
|
82
|
+
"upstreamModel": "glm-5-3",
|
|
83
|
+
"billingModel": "TEE/glm-5.3",
|
|
84
|
+
"providerPricingModel": "TEE/glm-5.3",
|
|
85
|
+
"teeTargetModel": "glm-5-3",
|
|
86
|
+
"maxInputTokens": 1048576,
|
|
87
|
+
"maxOutputTokens": 131072,
|
|
88
|
+
"thinkingMode": "glm-5.3",
|
|
89
|
+
"created": 1788391929,
|
|
90
|
+
"ownedBy": "nanogpt-private-mode",
|
|
91
|
+
"aliases": ["private/glm-5.3", "TEE/glm-5-3", "TEE/glm-5.3"]
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
"id": "private/glm-5-3-flash",
|
|
95
|
+
"name": "GLM 5.3 Flash Private",
|
|
96
|
+
"upstreamModel": "glm-5-3-flash",
|
|
97
|
+
"billingModel": "TEE/glm-5.3-flash",
|
|
98
|
+
"providerPricingModel": "TEE/glm-5.3-flash",
|
|
99
|
+
"teeTargetModel": "glm-5-3-flash",
|
|
100
|
+
"maxInputTokens": 1048576,
|
|
101
|
+
"maxOutputTokens": 131072,
|
|
102
|
+
"thinkingMode": "glm-5.3",
|
|
103
|
+
"created": 1788307200,
|
|
104
|
+
"ownedBy": "nanogpt-private-mode",
|
|
105
|
+
"aliases": ["private/glm-5.3-flash", "TEE/glm-5-3-flash", "TEE/glm-5.3-flash"]
|
|
106
|
+
},
|
|
74
107
|
{
|
|
75
108
|
"id": "private/gemma4-31b",
|
|
76
109
|
"name": "Gemma 4 31B Private",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nanogpt/private-mode",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.13",
|
|
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",
|