@chatpanel/gateway 0.5.5 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -12
- package/bin/chatpanel-gateway-bin.mjs +24 -0
- package/gateway.config.example.json +2 -1
- package/package.json +5 -6
- package/src/config.js +12 -8
- package/src/models.js +38 -0
- package/src/ner-engine.js +225 -0
- package/src/ner.js +27 -152
- package/src/redact.js +20 -9
- package/src/server.js +67 -9
- package/ner/README.md +0 -84
- package/ner/requirements.txt +0 -3
- package/ner/run.sh +0 -21
- package/ner/server.py +0 -95
- package/src/ner-assets.js +0 -8
package/README.md
CHANGED
|
@@ -78,18 +78,24 @@ export OPENAI_BASE_URL=http://127.0.0.1:4320/v1 # OpenAI / codex / aider / c
|
|
|
78
78
|
export ANTHROPIC_BASE_URL=http://127.0.0.1:4320 # Claude Code / Anthropic SDK
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
## Name/org redaction is built in (
|
|
81
|
+
## Name/org redaction is built in (in-process NER, no Python)
|
|
82
82
|
|
|
83
83
|
Deterministic redaction (emails, phones, cards, SSNs, API keys, IPs) needs no
|
|
84
|
-
setup. To also blind **names, organizations and locations**, the gateway
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
84
|
+
setup. To also blind **names, organizations and locations**, the gateway runs an
|
|
85
|
+
**in-process** entity detector — an ONNX transformer model via transformers.js —
|
|
86
|
+
with `ner.autostart` on (the default). There's **no Python, no second port, no
|
|
87
|
+
separate process**: the same model runs identically on macOS / Windows / Linux.
|
|
88
|
+
The model loads from `~/.chatpanel/models` and is downloaded once on first run if
|
|
89
|
+
absent (set `ner.allowDownload: false` to require it be pre-placed). It's
|
|
90
|
+
fail-open, so if the model can't load the gateway just runs deterministic-only.
|
|
91
|
+
Once the detector is ready, redaction switches to the `full` tier automatically.
|
|
92
|
+
|
|
93
|
+
The default model (`Xenova/bert-base-NER`) matches or beats spaCy's small model on
|
|
94
|
+
people/orgs/locations. Larger or alternative models can be installed from the
|
|
95
|
+
ChatPanel extension's **Gateway** settings.
|
|
96
|
+
|
|
97
|
+
Prefer a local LLM or your own external NER service? Set `redaction.detection`
|
|
98
|
+
yourself and the gateway won't load the bundled one (yours takes precedence).
|
|
93
99
|
|
|
94
100
|
## Configuration
|
|
95
101
|
|
|
@@ -106,9 +112,11 @@ env vars. See [`gateway.config.example.json`](gateway.config.example.json).
|
|
|
106
112
|
| `upstreams.openai.baseUrl` | `OPENAI_BASE_URL` | `https://api.openai.com` | api backend only |
|
|
107
113
|
| `upstreams.anthropic.baseUrl` | `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | api backend only |
|
|
108
114
|
| `redaction.tier` | `CHATPANEL_REDACTION_TIER` | `basic` | `basic` (regex) or `full` (+ NER + dictionary) |
|
|
109
|
-
| `redaction.detection` | — | _(
|
|
115
|
+
| `redaction.detection` | — | _(off → bundled engine)_ | external detector; set to override the bundled in-process one |
|
|
110
116
|
| `redaction.dictionary` | — | `[]` | custom `{ value\|pattern, type, alias? }` entries |
|
|
111
|
-
| `ner.autostart`
|
|
117
|
+
| `ner.autostart` | — | `true` | load the bundled in-process NER on startup |
|
|
118
|
+
| `ner.model` | — | `Xenova/bert-base-NER` | model id under `~/.chatpanel/models` |
|
|
119
|
+
| `ner.allowDownload` | — | `true` | download the model on first run if absent |
|
|
112
120
|
|
|
113
121
|
## Endpoints
|
|
114
122
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// Binary entry point for the Bun `--compile` standalone build (no Node required).
|
|
2
|
+
//
|
|
3
|
+
// A compiled single-file binary cannot embed onnxruntime-node's native dylib, so
|
|
4
|
+
// NER runs on the onnxruntime-web WASM runtime instead. We embed that runtime's
|
|
5
|
+
// two files INTO the executable here (Bun's `with { type: 'file' }` copies the
|
|
6
|
+
// asset in and yields a runtime path to it), then hand their paths to the NER
|
|
7
|
+
// engine via a global. src/ner-engine.js sees the global, forces the WASM backend,
|
|
8
|
+
// and points ORT at these files. Everything else is the normal CLI.
|
|
9
|
+
//
|
|
10
|
+
// The npm/Node package uses bin/chatpanel-gateway.js instead, which keeps the
|
|
11
|
+
// faster native onnxruntime-node runtime.
|
|
12
|
+
|
|
13
|
+
import { pathToFileURL } from 'node:url';
|
|
14
|
+
import wasmFile from '../assets/ort-wasm-simd-threaded.wasm' with { type: 'file' };
|
|
15
|
+
import mjsFile from '../assets/ort-wasm-simd-threaded.mjs' with { type: 'file' };
|
|
16
|
+
|
|
17
|
+
globalThis.__CHATPANEL_WASM_PATHS__ = {
|
|
18
|
+
wasm: pathToFileURL(wasmFile).href,
|
|
19
|
+
mjs: pathToFileURL(mjsFile).href,
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Delegate to the normal CLI (start / --install / --version / …). The global is
|
|
23
|
+
// already set, so when the gateway starts NER the engine uses the embedded WASM.
|
|
24
|
+
await import('./chatpanel-gateway.js');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -14,14 +14,11 @@
|
|
|
14
14
|
"start": "node bin/chatpanel-gateway.js",
|
|
15
15
|
"test": "node --test",
|
|
16
16
|
"typecheck": "tsc -p tsconfig.json",
|
|
17
|
-
"build:bin": "bash scripts/build-binaries.sh"
|
|
18
|
-
"gen:ner": "node scripts/gen-ner-assets.mjs",
|
|
19
|
-
"prepublishOnly": "node scripts/gen-ner-assets.mjs"
|
|
17
|
+
"build:bin": "bash scripts/build-binaries.sh"
|
|
20
18
|
},
|
|
21
19
|
"files": [
|
|
22
20
|
"src",
|
|
23
21
|
"bin",
|
|
24
|
-
"ner",
|
|
25
22
|
"gateway.config.example.json",
|
|
26
23
|
"LICENSE",
|
|
27
24
|
"README.md"
|
|
@@ -30,7 +27,9 @@
|
|
|
30
27
|
"node": ">=18"
|
|
31
28
|
},
|
|
32
29
|
"dependencies": {
|
|
33
|
-
"@chatpanel/pii": "^0.2.5"
|
|
30
|
+
"@chatpanel/pii": "^0.2.5",
|
|
31
|
+
"@huggingface/transformers": "^4.2.0",
|
|
32
|
+
"onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
|
|
34
33
|
},
|
|
35
34
|
"homepage": "https://chatpanel.net",
|
|
36
35
|
"repository": {
|
package/src/config.js
CHANGED
|
@@ -73,20 +73,24 @@ const DEFAULTS = {
|
|
|
73
73
|
// Local entity detector, passed straight to pii-detect.detectEntities.
|
|
74
74
|
// backend: 'off' | 'endpoint' (POST {text}->{entities}) | 'openai' (local LLM)
|
|
75
75
|
// url, model, timeoutMs, maxChars
|
|
76
|
-
//
|
|
77
|
-
//
|
|
76
|
+
// Leave this `off` to use the bundled in-process NER (see `ner` below). Set it
|
|
77
|
+
// only to point at YOUR OWN external detector (a custom NER or local LLM); that
|
|
78
|
+
// takes precedence over the bundled engine.
|
|
78
79
|
detection: { backend: 'off' },
|
|
79
80
|
// Per-request convenience: also redact the system prompt / system blocks.
|
|
80
81
|
redactSystem: true,
|
|
81
82
|
},
|
|
82
|
-
// Bundled
|
|
83
|
-
//
|
|
84
|
-
// org redaction works out of the box
|
|
85
|
-
//
|
|
83
|
+
// Bundled IN-PROCESS NER (ONNX via transformers.js). When autostart is on, the
|
|
84
|
+
// gateway loads the entity detector in-process — no Python, no second port — so
|
|
85
|
+
// name/org redaction works out of the box. The model loads from
|
|
86
|
+
// ~/.chatpanel/models and is downloaded once on first run if absent. Larger /
|
|
87
|
+
// alternative models can be installed from the extension's Gateway settings.
|
|
88
|
+
// Fails open: if the model can't load, the gateway runs deterministic-only.
|
|
86
89
|
ner: {
|
|
87
90
|
autostart: true,
|
|
88
|
-
|
|
89
|
-
|
|
91
|
+
model: 'Xenova/bert-base-NER',
|
|
92
|
+
allowDownload: true,
|
|
93
|
+
// Auto-bump redaction.tier to 'full' once the detector is ready (names/orgs).
|
|
90
94
|
enableFullTier: true,
|
|
91
95
|
},
|
|
92
96
|
|
package/src/models.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// Catalog of NER models the gateway can run, surfaced in the extension's Gateway
|
|
2
|
+
// settings so users can install a larger or multilingual detector. All are ONNX
|
|
3
|
+
// (transformers.js) token-classification models that emit PER/ORG/LOC — the only
|
|
4
|
+
// labels the redaction engine consumes. Sizes are the on-disk q8 footprint, approx.
|
|
5
|
+
//
|
|
6
|
+
// Adding a model: any Xenova/* token-classification model whose labels map to
|
|
7
|
+
// PER/ORG/LOC works (pii-detect normalizeEntities handles the label mapping).
|
|
8
|
+
// Verify it loads + detects before listing it here.
|
|
9
|
+
|
|
10
|
+
export const DEFAULT_MODEL = 'Xenova/bert-base-NER';
|
|
11
|
+
|
|
12
|
+
export const MODEL_CATALOG = [
|
|
13
|
+
{
|
|
14
|
+
id: 'Xenova/bert-base-NER',
|
|
15
|
+
label: 'English — standard',
|
|
16
|
+
lang: 'English',
|
|
17
|
+
approxMB: 105,
|
|
18
|
+
note: 'Default. Best English accuracy for people, organizations, and locations.',
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
id: 'Xenova/distilbert-base-multilingual-cased-ner-hrl',
|
|
22
|
+
label: 'Multilingual — compact',
|
|
23
|
+
lang: '10 languages',
|
|
24
|
+
approxMB: 150,
|
|
25
|
+
note: 'Covers en, es, fr, de, it, pt, nl, ar, zh, ru. Use for non-English text.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'Xenova/bert-base-multilingual-cased-ner-hrl',
|
|
29
|
+
label: 'Multilingual — large',
|
|
30
|
+
lang: '10 languages',
|
|
31
|
+
approxMB: 180,
|
|
32
|
+
note: 'Higher multilingual accuracy; larger download.',
|
|
33
|
+
},
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export function isKnownModel(id) {
|
|
37
|
+
return MODEL_CATALOG.some((m) => m.id === id);
|
|
38
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// In-process Named Entity Recognition — zero Python, zero second port.
|
|
2
|
+
//
|
|
3
|
+
// Replaces the old bundled spaCy microservice (a Python venv + uvicorn on :9009)
|
|
4
|
+
// with an ONNX transformer model run IN-PROCESS via transformers.js. This is what
|
|
5
|
+
// makes the gateway self-contained: nothing to install, no interpreter, no second
|
|
6
|
+
// port — the same model runs identically on macOS / Windows / Linux, and offline
|
|
7
|
+
// once the weights are on disk.
|
|
8
|
+
//
|
|
9
|
+
// Accuracy: the default (Xenova/bert-base-NER) matches or beats spaCy's
|
|
10
|
+
// en_core_web_sm on PERSON / ORG / LOCATION. Numeric PII (phone/card/SSN) is NOT
|
|
11
|
+
// the model's job — the deterministic regex layer in @chatpanel/pii handles that,
|
|
12
|
+
// same as before.
|
|
13
|
+
//
|
|
14
|
+
// Fail-open by design: if the model can't load (e.g. first run with no network and
|
|
15
|
+
// no cached weights), the gateway logs one line and runs deterministic-only.
|
|
16
|
+
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { existsSync, mkdirSync } from 'node:fs';
|
|
20
|
+
|
|
21
|
+
const DEFAULT_MODEL = 'Xenova/bert-base-NER';
|
|
22
|
+
|
|
23
|
+
// Download models from ChatPanel's own CDN (a branded, edge-cached proxy we
|
|
24
|
+
// control) rather than directly from Hugging Face — so a clean install depends only
|
|
25
|
+
// on chatpanel.net. Override with CHATPANEL_MODEL_BASE_URL (e.g. point at HF for
|
|
26
|
+
// dev, or an air-gapped mirror). Must end with '/' (transformers appends the model
|
|
27
|
+
// path template to it).
|
|
28
|
+
const MODEL_HOST = (process.env.CHATPANEL_MODEL_BASE_URL || 'https://dl.chatpanel.net/models/').replace(/\/*$/, '/');
|
|
29
|
+
|
|
30
|
+
let _state = 'off'; // 'off' | 'loading' | 'downloading' | 'ready' | 'error'
|
|
31
|
+
let _model = null; // active model id, e.g. 'Xenova/bert-base-NER'
|
|
32
|
+
let _pipe = null; // the loaded token-classification pipeline
|
|
33
|
+
let _err = null; // last error message (for /status)
|
|
34
|
+
let _initPromise = null; // single-flight init
|
|
35
|
+
let _lib = null; // memoized { env, pipeline } from transformers (imported once)
|
|
36
|
+
let _progress = null; // { model, file, pct } while a model is downloading, else null
|
|
37
|
+
|
|
38
|
+
// Where model weights live ON DISK. A real, writable, persistent user dir — NOT
|
|
39
|
+
// inside node_modules (wiped on reinstall, and absent entirely in a compiled
|
|
40
|
+
// binary). The settings "download larger models" flow writes here too.
|
|
41
|
+
export function modelRoot() {
|
|
42
|
+
return process.env.CHATPANEL_MODELS_DIR || join(os.homedir(), '.chatpanel', 'models');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Is the model already on disk? (q8 = the quantized weights we load.) When present
|
|
46
|
+
// we load fully offline — no network, a hard privacy guarantee.
|
|
47
|
+
export function modelOnDisk(modelId = _model || DEFAULT_MODEL) {
|
|
48
|
+
const dir = join(modelRoot(), ...modelId.split('/'));
|
|
49
|
+
return existsSync(join(dir, 'onnx', 'model_quantized.onnx')) || existsSync(join(dir, 'onnx', 'model.onnx'));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function state() { return _state; }
|
|
53
|
+
export function isReady() { return _state === 'ready' && !!_pipe; }
|
|
54
|
+
|
|
55
|
+
// Shape the /status `ner` block consumes. `url` is reported by the server as the
|
|
56
|
+
// public in-process contract (http://host:port/ner); we expose the model + state.
|
|
57
|
+
export function health() {
|
|
58
|
+
return {
|
|
59
|
+
configured: _state !== 'off',
|
|
60
|
+
ok: isReady(),
|
|
61
|
+
state: _state,
|
|
62
|
+
model: _model,
|
|
63
|
+
error: _err,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// PER/ORG/LOC/MISC come back as `entity_group`. We hand {value,type} straight to
|
|
68
|
+
// @chatpanel/pii, whose normalizeEntities() maps PER->PERSON, ORG->ORG,
|
|
69
|
+
// LOC->LOCATION and applies the user's category toggles — one source of truth.
|
|
70
|
+
//
|
|
71
|
+
// Subword healing: wordpiece tokenizers can occasionally split a name even with
|
|
72
|
+
// aggregation on (e.g. "Acme" -> "A" + "##cme"). A leaked "##" fragment or a
|
|
73
|
+
// 1-char head would NOT string-match the original text, leaving PII un-redacted.
|
|
74
|
+
// So we stitch a `##` continuation back onto the previous same-type span. This is
|
|
75
|
+
// belt-and-suspenders — most spans already arrive merged.
|
|
76
|
+
export async function detect(text, { signal } = {}) {
|
|
77
|
+
if (!isReady()) return [];
|
|
78
|
+
if (signal?.aborted) return [];
|
|
79
|
+
const out = await _pipe(String(text || ''), { aggregation_strategy: 'simple' });
|
|
80
|
+
const ents = [];
|
|
81
|
+
for (const e of out || []) {
|
|
82
|
+
let word = String(e.word ?? '');
|
|
83
|
+
const type = e.entity_group ?? e.entity ?? 'ENTITY';
|
|
84
|
+
if (word.startsWith('##') && ents.length && ents[ents.length - 1].type === type) {
|
|
85
|
+
// Continuation of the previous token (no space): "A" + "##cme Corp".
|
|
86
|
+
ents[ents.length - 1].value += word.slice(2);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
word = word.replace(/##/g, '').trim();
|
|
90
|
+
if (!word) continue;
|
|
91
|
+
ents.push({ value: word, type });
|
|
92
|
+
}
|
|
93
|
+
// Drop noise: a lone 1-char span that didn't get stitched is never useful PII.
|
|
94
|
+
return ents.filter((e) => e.value.length > 1);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Lets @chatpanel/pii's existing `endpoint` detection backend call us with NO real
|
|
98
|
+
// HTTP and NO second port: pii POSTs {text}->{entities} to a URL; we intercept by
|
|
99
|
+
// being passed as `fetchImpl`, run the model in-process, and return a Response.
|
|
100
|
+
// This reuses all of pii's caching / timeout / type-gating untouched.
|
|
101
|
+
export async function fetchAdapter(_url, opts = {}) {
|
|
102
|
+
let text = '';
|
|
103
|
+
try { text = JSON.parse(opts.body || '{}').text || ''; } catch { /* empty */ }
|
|
104
|
+
const entities = await detect(text, { signal: opts.signal });
|
|
105
|
+
return new Response(JSON.stringify({ entities }), {
|
|
106
|
+
status: 200,
|
|
107
|
+
headers: { 'content-type': 'application/json' },
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Live download progress for the model currently being fetched (or null). The
|
|
112
|
+
// extension's model manager polls this. { model, file, pct }.
|
|
113
|
+
export function progress() { return _progress; }
|
|
114
|
+
|
|
115
|
+
// Import transformers ONCE and configure its env (model dir + WASM in a binary).
|
|
116
|
+
async function ensureLib() {
|
|
117
|
+
if (_lib) return _lib;
|
|
118
|
+
const root = modelRoot();
|
|
119
|
+
try { mkdirSync(root, { recursive: true }); } catch { /* best effort */ }
|
|
120
|
+
|
|
121
|
+
// In a Bun --compile single-file binary there is no native onnxruntime-node (its
|
|
122
|
+
// dylib can't be embedded), so the binary entry embeds the onnxruntime-web WASM
|
|
123
|
+
// runtime and hands us its paths via this global. When present we force the WASM
|
|
124
|
+
// backend (transformers picks it when it doesn't see a Node env) BEFORE importing
|
|
125
|
+
// transformers. The npm/Node path leaves this unset and uses the faster native
|
|
126
|
+
// runtime.
|
|
127
|
+
const wasmPaths = globalThis.__CHATPANEL_WASM_PATHS__ || null;
|
|
128
|
+
if (wasmPaths) {
|
|
129
|
+
try { Object.defineProperty(process, 'release', { value: { ...process.release, name: 'bun' }, configurable: true }); } catch { /* ignore */ }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const { env, pipeline } = await import('@huggingface/transformers');
|
|
133
|
+
env.cacheDir = root; // where remote downloads are cached
|
|
134
|
+
env.localModelPath = root; // where local loads resolve — same dir, we control it
|
|
135
|
+
try { env.remoteHost = MODEL_HOST; } catch { /* optional */ }
|
|
136
|
+
try { env.backends.onnx.wasm.numThreads = 1; } catch { /* optional */ }
|
|
137
|
+
if (wasmPaths) {
|
|
138
|
+
// Single-thread + no proxy + preloaded wasm bytes — avoids the `blob:` ESM-scheme
|
|
139
|
+
// failure when ORT-web runs outside a browser.
|
|
140
|
+
try { env.backends.onnx.wasm.proxy = false; env.backends.onnx.wasm.wasmPaths = wasmPaths; } catch { /* optional */ }
|
|
141
|
+
}
|
|
142
|
+
_lib = { env, pipeline };
|
|
143
|
+
return _lib;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// (Re)load a specific model into _pipe. Downloads it first if missing (and allowed).
|
|
147
|
+
// Reusable by init() and setModel(). Fail-open: on error, state='error', _pipe stays
|
|
148
|
+
// whatever it was (so a failed SWITCH doesn't kill a working detector).
|
|
149
|
+
async function loadModel(modelId, { log = () => {}, allowDownload = true } = {}) {
|
|
150
|
+
const prevPipe = _pipe;
|
|
151
|
+
const prevModel = _model;
|
|
152
|
+
let lib;
|
|
153
|
+
try {
|
|
154
|
+
lib = await ensureLib();
|
|
155
|
+
} catch (e) {
|
|
156
|
+
_state = 'error'; _err = `engine load failed: ${e.message}`;
|
|
157
|
+
log(`[ner] transformers.js not available (${e.message}) — deterministic-only`);
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const haveLocal = modelOnDisk(modelId);
|
|
162
|
+
// Offline if cached (fast + private); only reach the network when missing.
|
|
163
|
+
lib.env.allowRemoteModels = haveLocal ? false : !!allowDownload;
|
|
164
|
+
if (!haveLocal && !allowDownload) {
|
|
165
|
+
_state = 'error'; _err = 'model not on disk and downloads disabled';
|
|
166
|
+
log(`[ner] model ${modelId} not installed and downloads disabled — deterministic-only`);
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
_state = haveLocal ? 'loading' : 'downloading';
|
|
171
|
+
if (!haveLocal) { _progress = { model: modelId, file: null, pct: 0 }; log(`[ner] downloading model ${modelId} (one-time)…`); }
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
const pipe = await lib.pipeline('token-classification', modelId, {
|
|
175
|
+
dtype: 'q8',
|
|
176
|
+
progress_callback: (p) => {
|
|
177
|
+
if (!p) return;
|
|
178
|
+
const pct = typeof p.progress === 'number' ? Math.round(p.progress) : (_progress?.pct ?? 0);
|
|
179
|
+
if (p.status === 'progress' || p.status === 'download' || p.status === 'initiate') {
|
|
180
|
+
_progress = { model: modelId, file: p.file || _progress?.file || null, pct };
|
|
181
|
+
} else if (p.status === 'done' && p.file) {
|
|
182
|
+
log(`[ner] fetched ${p.file}`);
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
// Swap in the new pipeline, dispose the old one (free its WASM/native session).
|
|
187
|
+
_pipe = pipe; _model = modelId; _state = 'ready'; _err = null; _progress = null;
|
|
188
|
+
if (prevPipe && prevPipe !== pipe) { try { await prevPipe.dispose?.(); } catch { /* ignore */ } }
|
|
189
|
+
log(`[ner] ready — model ${modelId} (in-process, no Python) — entity detection active`);
|
|
190
|
+
return true;
|
|
191
|
+
} catch (e) {
|
|
192
|
+
_err = e.message; _progress = null;
|
|
193
|
+
// Keep any previously-working detector rather than going dark on a bad switch.
|
|
194
|
+
if (prevPipe) { _pipe = prevPipe; _model = prevModel; _state = 'ready'; }
|
|
195
|
+
else { _state = 'error'; }
|
|
196
|
+
log(`[ner] model load failed (${e.message})${prevPipe ? ' — keeping previous model' : ' — deterministic-only'}`);
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Load the default/configured model once at startup. Returns a promise that
|
|
202
|
+
// resolves when ready (or after a fail-open error). `cfg` = { model?, allowDownload?, onLog? }.
|
|
203
|
+
export function init(cfg = {}) {
|
|
204
|
+
if (_initPromise) return _initPromise;
|
|
205
|
+
const log = typeof cfg.onLog === 'function' ? cfg.onLog : () => {};
|
|
206
|
+
_model = cfg.model || DEFAULT_MODEL;
|
|
207
|
+
_state = 'loading';
|
|
208
|
+
_initPromise = loadModel(_model, { log, allowDownload: cfg.allowDownload !== false });
|
|
209
|
+
return _initPromise;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Switch to a different model (download it first if needed). Used by the model
|
|
213
|
+
// manager. Returns true on success. A failed switch keeps the current detector.
|
|
214
|
+
export async function setModel(modelId, opts = {}) {
|
|
215
|
+
const log = typeof opts.onLog === 'function' ? opts.onLog : () => {};
|
|
216
|
+
const allowDownload = opts.allowDownload !== false;
|
|
217
|
+
if (!modelId) return false;
|
|
218
|
+
if (modelId === _model && isReady()) return true;
|
|
219
|
+
return loadModel(modelId, { log, allowDownload });
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Test hook: reset module state (used by unit tests).
|
|
223
|
+
export function _reset() {
|
|
224
|
+
_state = 'off'; _model = null; _pipe = null; _err = null; _initPromise = null; _lib = null; _progress = null;
|
|
225
|
+
}
|
package/src/ner.js
CHANGED
|
@@ -1,168 +1,43 @@
|
|
|
1
|
-
// Managed
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Managed in-process NER. When cfg.ner.autostart is on, launching the gateway
|
|
2
|
+
// loads the bundled ONNX entity detector (./ner-engine.js) and flips redaction to
|
|
3
|
+
// full tier once it's ready — name/org redaction with a single command, no second
|
|
4
|
+
// process, no second port, no Python.
|
|
4
5
|
//
|
|
5
|
-
// Fail-open by design: if
|
|
6
|
-
//
|
|
7
|
-
//
|
|
6
|
+
// Fail-open by design: if the model can't load (e.g. first run, no network, no
|
|
7
|
+
// cached weights), we log a one-line hint and the gateway keeps running with
|
|
8
|
+
// deterministic-only redaction. redact.js consults the engine directly, so we do
|
|
9
|
+
// NOT mutate cfg.redaction.detection here (that field is reserved for a user's own
|
|
10
|
+
// external detector, which takes precedence — see below).
|
|
8
11
|
|
|
9
|
-
import
|
|
10
|
-
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { dirname, join } from 'node:path';
|
|
12
|
-
import { existsSync, mkdirSync, writeFileSync, readFileSync, chmodSync } from 'node:fs';
|
|
13
|
-
import os from 'node:os';
|
|
14
|
-
import { NER_ASSETS } from './ner-assets.js';
|
|
12
|
+
import * as engine from './ner-engine.js';
|
|
15
13
|
|
|
16
|
-
// Where the NER server lives ON DISK. We always run from a real, writable user dir
|
|
17
|
-
// (~/.chatpanel/ner) rather than the package's ner/ — because a STANDALONE BINARY
|
|
18
|
-
// has no ner/ on the filesystem at all (it's inside the SEA snapshot), and a global
|
|
19
|
-
// npm dir may be read-only. The venv + installed deps persist here across restarts.
|
|
20
|
-
function materializeNerDir() {
|
|
21
|
-
const dir = join(os.homedir(), '.chatpanel', 'ner');
|
|
22
|
-
try {
|
|
23
|
-
mkdirSync(dir, { recursive: true });
|
|
24
|
-
for (const [name, b64] of Object.entries(NER_ASSETS)) {
|
|
25
|
-
const target = join(dir, name);
|
|
26
|
-
const want = Buffer.from(b64, 'base64');
|
|
27
|
-
// Refresh the scripts if changed (pick up updates), but never touch .venv.
|
|
28
|
-
const have = existsSync(target) ? readFileSync(target) : null;
|
|
29
|
-
if (!have || !have.equals(want)) writeFileSync(target, want);
|
|
30
|
-
if (name.endsWith('.sh')) { try { chmodSync(target, 0o755); } catch { /* best effort */ } }
|
|
31
|
-
}
|
|
32
|
-
return dir;
|
|
33
|
-
} catch (e) {
|
|
34
|
-
console.log(`[ner] could not materialize NER dir at ${dir} (${e.message})`);
|
|
35
|
-
return null;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
// A login service (LaunchAgent / systemd) inherits a MINIMAL PATH — not your
|
|
40
|
-
// shell's — so `bash` and a pyenv/homebrew `python3` aren't found. Resolve bash
|
|
41
|
-
// absolutely and enrich PATH with the usual locations so run.sh + python3 work.
|
|
42
|
-
function resolveBash() {
|
|
43
|
-
for (const p of ['/bin/bash', '/usr/bin/bash', '/usr/local/bin/bash', '/opt/homebrew/bin/bash']) {
|
|
44
|
-
if (existsSync(p)) return p;
|
|
45
|
-
}
|
|
46
|
-
return 'bash';
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
function enrichedPath(home = os.homedir(), base = process.env.PATH || '') {
|
|
50
|
-
const extra = [
|
|
51
|
-
'/opt/homebrew/bin', '/usr/local/bin', '/usr/bin', '/bin', '/usr/sbin', '/sbin',
|
|
52
|
-
join(home, '.pyenv', 'shims'), join(home, '.pyenv', 'bin'),
|
|
53
|
-
join(home, '.local', 'bin'),
|
|
54
|
-
];
|
|
55
|
-
return [...new Set([...base.split(':').filter(Boolean), ...extra])].join(':');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
59
|
-
|
|
60
|
-
// Probe the ACTUAL /ner contract (POST {text} -> {entities}), not /health: many
|
|
61
|
-
// NER servers (incl. a user's own) expose only /ner. If anything answers here, we
|
|
62
|
-
// can use it as the detector.
|
|
63
|
-
async function nerReachable(port, signal) {
|
|
64
|
-
try {
|
|
65
|
-
const res = await fetch(`http://127.0.0.1:${port}/ner`, {
|
|
66
|
-
method: 'POST',
|
|
67
|
-
headers: { 'content-type': 'application/json' },
|
|
68
|
-
body: JSON.stringify({ text: 'ping' }),
|
|
69
|
-
signal,
|
|
70
|
-
});
|
|
71
|
-
return res.ok;
|
|
72
|
-
} catch {
|
|
73
|
-
return false;
|
|
74
|
-
}
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
// Launch + supervise the NER server (or adopt one already on the port). Returns
|
|
78
|
-
// { stop() } or null if not started. Mutates cfg.redaction once NER answers.
|
|
79
14
|
export function startNer(cfg) {
|
|
80
15
|
const n = cfg.ner;
|
|
81
16
|
if (!n || !n.autostart) return null;
|
|
82
17
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// Respect a USER-configured detector pointing elsewhere (a custom NER, a local
|
|
87
|
-
// LLM) — don't relaunch — but still apply the full-tier bump. If detection is
|
|
88
|
-
// off OR points at our own bundled NER, fall through and (re)launch/adopt it,
|
|
89
|
-
// so a persisted "detection→bundled" config doesn't leave NER actually down.
|
|
18
|
+
// Respect a USER-configured external detector (a custom NER endpoint or a local
|
|
19
|
+
// LLM): don't load the bundled engine — just apply the full-tier bump so their
|
|
20
|
+
// detector is actually used.
|
|
90
21
|
const det = cfg.redaction?.detection;
|
|
91
|
-
|
|
92
|
-
if (userDetector) {
|
|
22
|
+
if (det && det.backend && det.backend !== 'off') {
|
|
93
23
|
if (n.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
|
|
94
24
|
console.log(`[ner] using configured detector (${det.backend} ${det.url || ''}) — full tier ${cfg.redaction.tier === 'full' ? 'on' : 'off'}`);
|
|
95
25
|
return null;
|
|
96
26
|
}
|
|
97
|
-
let stopped = false;
|
|
98
|
-
let child = null;
|
|
99
|
-
const ac = new AbortController();
|
|
100
|
-
|
|
101
|
-
const wire = (how) => {
|
|
102
|
-
cfg.redaction.detection = { backend: 'endpoint', url: `http://127.0.0.1:${port}/ner`, timeoutMs: 1500, maxChars: 8000 };
|
|
103
|
-
if (n.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
|
|
104
|
-
console.log(`[ner] ${how} on http://127.0.0.1:${port}/ner — entity detection active (tier: ${cfg.redaction.tier})`);
|
|
105
|
-
};
|
|
106
27
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
child = spawn(resolveBash(), ['run.sh'], {
|
|
118
|
-
cwd: nerDir,
|
|
119
|
-
env: {
|
|
120
|
-
...process.env,
|
|
121
|
-
PORT: String(port),
|
|
122
|
-
PATH: enrichedPath(),
|
|
123
|
-
// Force official PyPI: a machine pinned to a private/corp index (in
|
|
124
|
-
// pip.conf) can't reach it off-VPN, which breaks the one-time install.
|
|
125
|
-
PIP_INDEX_URL: process.env.CHATPANEL_PIP_INDEX_URL || 'https://pypi.org/simple',
|
|
126
|
-
PIP_EXTRA_INDEX_URL: '',
|
|
127
|
-
PIP_DISABLE_PIP_VERSION_CHECK: '1',
|
|
128
|
-
},
|
|
129
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
130
|
-
});
|
|
131
|
-
} catch (e) {
|
|
132
|
-
console.log(`[ner] could not launch bundled NER (${e.message}) — deterministic redaction only`);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
let firstRun = true;
|
|
136
|
-
child.stdout?.on('data', (b) => {
|
|
137
|
-
if (firstRun && /installing dependencies/i.test(b.toString())) {
|
|
138
|
-
firstRun = false;
|
|
139
|
-
console.log('[ner] first run: creating venv + installing spaCy (one-time, may take a minute)…');
|
|
140
|
-
}
|
|
141
|
-
});
|
|
142
|
-
child.stderr?.on('data', () => { /* uvicorn logs to stderr; swallow */ });
|
|
143
|
-
child.on('error', (e) => console.log(`[ner] failed to start (${e.message}). Is python3 installed? Falling back to deterministic redaction.`));
|
|
144
|
-
child.on('exit', (code) => {
|
|
145
|
-
if (code && code !== 0 && !stopped) {
|
|
146
|
-
// The bundled one couldn't bind (often the port is taken by another NER).
|
|
147
|
-
// If SOMETHING answers /ner there, adopt it instead of giving up.
|
|
148
|
-
nerReachable(port, ac.signal).then((ok) => { if (ok && !stopped) wire('adopted NER'); else if (!stopped) console.log(`[ner] server exited (code ${code}); deterministic-only.`); });
|
|
149
|
-
}
|
|
150
|
-
});
|
|
151
|
-
|
|
152
|
-
// 3) Poll for readiness; wire detection when up.
|
|
153
|
-
const deadline = Date.now() + 300_000; // generous: first run installs deps
|
|
154
|
-
while (Date.now() < deadline && !stopped) {
|
|
155
|
-
if (await nerReachable(port, ac.signal)) { wire('ready'); return; }
|
|
156
|
-
await sleep(1000);
|
|
28
|
+
let stopped = false;
|
|
29
|
+
engine.init({
|
|
30
|
+
model: n.model,
|
|
31
|
+
allowDownload: n.allowDownload !== false,
|
|
32
|
+
onLog: (m) => { if (!stopped) console.log(m); },
|
|
33
|
+
}).then(() => {
|
|
34
|
+
if (stopped) return;
|
|
35
|
+
if (engine.isReady() && n.enableFullTier && cfg.redaction.tier !== 'full') {
|
|
36
|
+
cfg.redaction.tier = 'full';
|
|
37
|
+
console.log(`[ner] full tier on — name/org redaction active`);
|
|
157
38
|
}
|
|
158
|
-
|
|
159
|
-
})();
|
|
39
|
+
}).catch((e) => { if (!stopped) console.log(`[ner] init error (${e.message}) — deterministic-only`); });
|
|
160
40
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
stopped = true;
|
|
164
|
-
ac.abort();
|
|
165
|
-
try { child?.kill('SIGTERM'); } catch { /* ignore */ }
|
|
166
|
-
};
|
|
167
|
-
return { stop };
|
|
41
|
+
// Nothing to kill (no child process); just stop logging after shutdown.
|
|
42
|
+
return { stop() { stopped = true; } };
|
|
168
43
|
}
|
package/src/redact.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
// extension's pii-pipeline.)
|
|
10
10
|
|
|
11
11
|
import { createVault, redactText, detectEntities, effectiveTier, gatedDictionary } from '@chatpanel/pii';
|
|
12
|
+
import * as engine from './ner-engine.js';
|
|
12
13
|
|
|
13
14
|
// tier: 'basic' | 'full'. For 'full' we run the local detector over the combined
|
|
14
15
|
// text to harvest names/orgs, then redact every segment against that entity set.
|
|
@@ -25,17 +26,27 @@ export async function redactSegments(segments, redactionCfg, { signal, isPro = t
|
|
|
25
26
|
const tier = effectiveTier({ tier: redactionCfg.tier }, isPro);
|
|
26
27
|
const dictionary = gatedDictionary(redactionCfg, isPro);
|
|
27
28
|
|
|
29
|
+
// Detection source: a USER-configured external detector takes precedence; else
|
|
30
|
+
// the bundled in-process engine (no second port — we hand pii-detect a fetchImpl
|
|
31
|
+
// that runs the model in-process instead of doing real HTTP). Either way we reuse
|
|
32
|
+
// @chatpanel/pii's caching / timeout / type-gating — one source of truth.
|
|
33
|
+
const det = redactionCfg.detection;
|
|
34
|
+
const useExternal = !!(det && det.backend && det.backend !== 'off');
|
|
35
|
+
const useEngine = !useExternal && engine.isReady();
|
|
36
|
+
|
|
28
37
|
let entities = [];
|
|
29
|
-
if (tier === 'full' &&
|
|
30
|
-
// One detection pass over the joined text —
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
if (tier === 'full' && (useExternal || useEngine)) {
|
|
39
|
+
// One detection pass over the joined text — cached + fail-open, so a slow/broken
|
|
40
|
+
// detector never blocks the request (the deterministic layer still runs). Give it
|
|
41
|
+
// a generous CEILING (matching the extension): a fast detector returns in well
|
|
42
|
+
// under a second, but a cold one must be allowed to finish; on timeout the turn
|
|
43
|
+
// falls back to dictionary/deterministic-only redaction.
|
|
44
|
+
const detection = useEngine
|
|
45
|
+
? { backend: 'endpoint', url: 'inproc:ner', timeoutMs: 30000, maxChars: 8000, types: det?.types }
|
|
46
|
+
: { ...det, timeoutMs: Math.max(Number(det.timeoutMs) || 0, 30000) };
|
|
47
|
+
const fetchImpl = useEngine ? engine.fetchAdapter : undefined;
|
|
37
48
|
try {
|
|
38
|
-
entities = await detectEntities(texts.join('\n\n'), { detection }, { signal });
|
|
49
|
+
entities = await detectEntities(texts.join('\n\n'), { detection }, { signal, fetchImpl });
|
|
39
50
|
} catch {
|
|
40
51
|
entities = [];
|
|
41
52
|
}
|
package/src/server.js
CHANGED
|
@@ -26,6 +26,8 @@ import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
|
|
|
26
26
|
import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
|
|
27
27
|
import { shaperFor } from './shape.js';
|
|
28
28
|
import { startNer } from './ner.js';
|
|
29
|
+
import * as nerEngine from './ner-engine.js';
|
|
30
|
+
import { MODEL_CATALOG, isKnownModel } from './models.js';
|
|
29
31
|
import { resolvePro, meter, usage } from './freegate.js';
|
|
30
32
|
import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
|
|
31
33
|
import { resolveDestination, aggregateModelsAsync } from './router.js';
|
|
@@ -33,7 +35,7 @@ import * as openai from './openai.js';
|
|
|
33
35
|
import * as responses from './responses.js';
|
|
34
36
|
import * as anthropic from './anthropic.js';
|
|
35
37
|
|
|
36
|
-
export const VERSION = '0.
|
|
38
|
+
export const VERSION = '0.6.0';
|
|
37
39
|
|
|
38
40
|
const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
|
|
39
41
|
|
|
@@ -41,7 +43,7 @@ const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'anti
|
|
|
41
43
|
// the extension's AUTO mode via the SAME shared ranker. We narrow only tools whose
|
|
42
44
|
// name looks like an MCP tool (server-prefixed) so a client's CORE tools (bash,
|
|
43
45
|
// read, edit…) are never dropped — that would break agent clients like OpenCode.
|
|
44
|
-
const DEFAULT_GATEWAY_TOOL_CAP =
|
|
46
|
+
const DEFAULT_GATEWAY_TOOL_CAP = 16;
|
|
45
47
|
const MCP_NAME_RE = /^mcp[_-]/i;
|
|
46
48
|
const toolName = (t) => (t && t.function && t.function.name) || (t && t.name) || '';
|
|
47
49
|
const toolDesc = (t) => (t && t.function && t.function.description) || (t && t.description) || '';
|
|
@@ -162,16 +164,30 @@ function sendJson(res, status, obj) {
|
|
|
162
164
|
res.end(JSON.stringify(obj));
|
|
163
165
|
}
|
|
164
166
|
|
|
165
|
-
//
|
|
166
|
-
// detector is wired (
|
|
167
|
+
// A USER-configured external detector's URL and its sibling /health. Returns null
|
|
168
|
+
// when no external detector is wired (the bundled in-process engine is handled
|
|
169
|
+
// separately in probeNerHealth / the /ner route).
|
|
167
170
|
function nerBaseUrl(cfg) {
|
|
168
171
|
const url = cfg.redaction?.detection?.url;
|
|
169
172
|
if (!url || cfg.redaction?.detection?.backend === 'off') return null;
|
|
170
173
|
return url;
|
|
171
174
|
}
|
|
172
175
|
|
|
173
|
-
//
|
|
176
|
+
// Health of the detector for /status. The bundled IN-PROCESS engine takes
|
|
177
|
+
// precedence; its public contract URL is the gateway's own /ner (no second port).
|
|
178
|
+
// A user-configured external detector is probed over HTTP as before.
|
|
174
179
|
async function probeNerHealth(cfg) {
|
|
180
|
+
if (nerEngine.state() !== 'off') {
|
|
181
|
+
const h = nerEngine.health();
|
|
182
|
+
return {
|
|
183
|
+
configured: h.configured,
|
|
184
|
+
ok: h.ok,
|
|
185
|
+
state: h.state,
|
|
186
|
+
model: h.model,
|
|
187
|
+
error: h.error || null,
|
|
188
|
+
url: h.configured ? `http://${cfg.host}:${cfg.port}/ner` : null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
175
191
|
const url = nerBaseUrl(cfg);
|
|
176
192
|
if (!url) return { configured: false, ok: false, url: null, model: null };
|
|
177
193
|
try {
|
|
@@ -381,16 +397,30 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
381
397
|
uptimeSeconds: Math.floor((Date.now() - STARTED_AT) / 1000),
|
|
382
398
|
});
|
|
383
399
|
}
|
|
384
|
-
//
|
|
385
|
-
//
|
|
400
|
+
// The detector, on the gateway's own port (no second port). GET → health;
|
|
401
|
+
// POST {text} → {entities}. The bundled engine runs IN-PROCESS; a user's own
|
|
402
|
+
// external detector (if configured) is proxied for back-compat.
|
|
386
403
|
if (pathname === '/ner') {
|
|
387
|
-
const url = nerBaseUrl(cfg);
|
|
388
|
-
if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
|
|
389
404
|
if (req.method === 'GET') {
|
|
390
405
|
const health = await probeNerHealth(cfg);
|
|
391
406
|
return sendJson(res, health.ok ? 200 : 503, health);
|
|
392
407
|
}
|
|
393
408
|
if (req.method === 'POST') {
|
|
409
|
+
// In-process engine path.
|
|
410
|
+
if (nerEngine.state() !== 'off') {
|
|
411
|
+
try {
|
|
412
|
+
const body = await readBody(req, cfg.maxBodyBytes);
|
|
413
|
+
let text = '';
|
|
414
|
+
try { text = JSON.parse(body.toString('utf8'))?.text || ''; } catch { /* empty */ }
|
|
415
|
+
const entities = await nerEngine.detect(text);
|
|
416
|
+
return sendJson(res, 200, { entities });
|
|
417
|
+
} catch (e) {
|
|
418
|
+
return sendJson(res, 500, { error: { message: `NER error: ${e.message}`, type: 'ner_error' } });
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
// External detector proxy (user-configured endpoint).
|
|
422
|
+
const url = nerBaseUrl(cfg);
|
|
423
|
+
if (!url) return sendJson(res, 503, { error: { message: 'NER not configured — deterministic-only redaction', type: 'ner_off' } });
|
|
394
424
|
try {
|
|
395
425
|
const body = await readBody(req, cfg.maxBodyBytes);
|
|
396
426
|
const r = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: AbortSignal.timeout(8000) });
|
|
@@ -402,6 +432,34 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
402
432
|
}
|
|
403
433
|
}
|
|
404
434
|
}
|
|
435
|
+
// Model manager (the extension's Gateway settings drive these). GET lists the
|
|
436
|
+
// catalog with install state + live download progress; POST switches the active
|
|
437
|
+
// model (downloading it first if needed) and persists the choice.
|
|
438
|
+
if (pathname === '/ner/models') {
|
|
439
|
+
if (req.method === 'GET') {
|
|
440
|
+
const available = MODEL_CATALOG.map((m) => ({ ...m, installed: nerEngine.modelOnDisk(m.id) }));
|
|
441
|
+
return sendJson(res, 200, {
|
|
442
|
+
active: nerEngine.health().model || cfg.ner?.model || null,
|
|
443
|
+
state: nerEngine.state(),
|
|
444
|
+
progress: nerEngine.progress(),
|
|
445
|
+
available,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
if (req.method === 'POST') {
|
|
449
|
+
let body = null;
|
|
450
|
+
try { body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')); } catch { body = null; }
|
|
451
|
+
const id = body && typeof body.id === 'string' ? body.id : null;
|
|
452
|
+
if (!id || !isKnownModel(id)) return sendJson(res, 400, { error: { message: 'unknown model id', type: 'bad_model' } });
|
|
453
|
+
// Persist first so a restart keeps the choice, then (re)load. Don't block the
|
|
454
|
+
// response on a possibly-long download — the client polls GET for progress.
|
|
455
|
+
if (cfg.ner) cfg.ner.model = id; else cfg.ner = { autostart: true, model: id, allowDownload: true, enableFullTier: true };
|
|
456
|
+
try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
|
|
457
|
+
nerEngine.setModel(id, { onLog: (m) => console.log(m) }).then((ok) => {
|
|
458
|
+
if (ok && cfg.ner?.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
|
|
459
|
+
});
|
|
460
|
+
return sendJson(res, 202, { accepted: true, active: id, state: nerEngine.state(), progress: nerEngine.progress() });
|
|
461
|
+
}
|
|
462
|
+
}
|
|
405
463
|
if (pathname === '/logs' && req.method === 'GET') {
|
|
406
464
|
return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
|
|
407
465
|
}
|
package/ner/README.md
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# ChatPanel — local NER helper (spaCy)
|
|
2
|
-
|
|
3
|
-
A ~30-line local service that lets ChatPanel **auto-redact names, organizations,
|
|
4
|
-
and locations** before anything is sent to a chat model. Detection runs entirely
|
|
5
|
-
on your machine; the model only ever sees placeholders like `[[PERSON_1]]`.
|
|
6
|
-
|
|
7
|
-
ChatPanel's redaction contract is simple — any local HTTP service works:
|
|
8
|
-
|
|
9
|
-
```
|
|
10
|
-
POST /ner { "text": "..." }
|
|
11
|
-
→ { "entities": [ { "value": "Alex", "type": "PERSON" }, ... ] }
|
|
12
|
-
```
|
|
13
|
-
|
|
14
|
-
(spaCy's `{ "ents": [{ "text", "label" }] }` shape is also accepted, as is a
|
|
15
|
-
local OpenAI-compatible LLM — see "Other detectors" below.)
|
|
16
|
-
|
|
17
|
-
## Quick start
|
|
18
|
-
|
|
19
|
-
Most machines block installing Python packages globally, so use a virtual env:
|
|
20
|
-
|
|
21
|
-
```bash
|
|
22
|
-
cd helpers/ner-server
|
|
23
|
-
|
|
24
|
-
python3 -m venv .venv
|
|
25
|
-
source .venv/bin/activate # Windows: .venv\Scripts\activate
|
|
26
|
-
pip install -r requirements.txt
|
|
27
|
-
python -m spacy download en_core_web_sm
|
|
28
|
-
|
|
29
|
-
uvicorn server:app --port 9009 # the file is server.py → import path "server:app"
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
…or just run the bundled script (does all of the above):
|
|
33
|
-
|
|
34
|
-
```bash
|
|
35
|
-
./run.sh # PORT=9100 ./run.sh to change the port
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
Check it's up: `curl http://127.0.0.1:9009/health`
|
|
39
|
-
|
|
40
|
-
## Point ChatPanel at it
|
|
41
|
-
|
|
42
|
-
**Settings → Privacy** (or the 🛡 button in the chat composer):
|
|
43
|
-
|
|
44
|
-
| Field | Value |
|
|
45
|
-
|------|-------|
|
|
46
|
-
| Redaction | **On — + AI detection** |
|
|
47
|
-
| Detector | **Local NER service (spaCy / Presidio)** |
|
|
48
|
-
| Detector URL | `http://127.0.0.1:9009/ner` |
|
|
49
|
-
| Redact types | People / Organizations / Locations / Numbers (your choice) |
|
|
50
|
-
|
|
51
|
-
Now `my name is Alex from Denver` is sent to the model as
|
|
52
|
-
`my name is [[PERSON_1]] from [[LOCATION_1]]`, and the reply is restored to the
|
|
53
|
-
real values in your view.
|
|
54
|
-
|
|
55
|
-
> Turning **Locations** off keeps city names readable (useful for "how far is X
|
|
56
|
-
> from Y" questions) while still redacting people.
|
|
57
|
-
|
|
58
|
-
## Accuracy vs. speed
|
|
59
|
-
|
|
60
|
-
`en_core_web_sm` is small and fast (good default). For fewer misses:
|
|
61
|
-
|
|
62
|
-
```bash
|
|
63
|
-
python -m spacy download en_core_web_md # or en_core_web_trf (best, heavier)
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
then change `MODEL` in `server.py`. Small models can over-tag short acronyms as
|
|
67
|
-
`ORG`; ChatPanel drops noisy short numerics/dates automatically and lets you turn
|
|
68
|
-
off whole categories.
|
|
69
|
-
|
|
70
|
-
## Other detectors
|
|
71
|
-
|
|
72
|
-
ChatPanel doesn't care what's behind the URL, as long as it returns the entity
|
|
73
|
-
shape above. Drop-in alternatives:
|
|
74
|
-
|
|
75
|
-
- **Microsoft Presidio** — `presidio-analyzer` behind a small FastAPI wrapper
|
|
76
|
-
(returns `{ "results": [...] }`, also accepted).
|
|
77
|
-
- **A local LLM** — set the detector to **Local LLM (OpenAI-compatible)** and
|
|
78
|
-
point it at Ollama / LM Studio / llama.cpp (e.g. `http://127.0.0.1:11434`);
|
|
79
|
-
ChatPanel prompts it for strict JSON entities. Slower than spaCy, no extra
|
|
80
|
-
service to run if you already have a local model. See `../local-model.md`.
|
|
81
|
-
|
|
82
|
-
Everything is local and latency-guarded (cached + timed out + fail-open): if the
|
|
83
|
-
detector is slow or down, ChatPanel falls back to deterministic redaction so chat
|
|
84
|
-
never blocks.
|
package/ner/requirements.txt
DELETED
package/ner/run.sh
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# One-shot: create the venv (if needed), install deps + the spaCy model, and serve.
|
|
3
|
-
# Usage: ./run.sh (defaults to port 9009)
|
|
4
|
-
# PORT=9100 ./run.sh
|
|
5
|
-
set -euo pipefail
|
|
6
|
-
cd "$(dirname "$0")"
|
|
7
|
-
|
|
8
|
-
if [ ! -d .venv ]; then
|
|
9
|
-
echo "→ creating virtual env (.venv)…"
|
|
10
|
-
python3 -m venv .venv
|
|
11
|
-
fi
|
|
12
|
-
# shellcheck disable=SC1091
|
|
13
|
-
source .venv/bin/activate
|
|
14
|
-
|
|
15
|
-
echo "→ installing dependencies…"
|
|
16
|
-
pip install -q --upgrade pip
|
|
17
|
-
pip install -q -r requirements.txt
|
|
18
|
-
python -c "import en_core_web_sm" >/dev/null 2>&1 || python -m spacy download en_core_web_sm
|
|
19
|
-
|
|
20
|
-
echo "→ serving on http://127.0.0.1:${PORT:-9009}/ner (Ctrl-C to stop)"
|
|
21
|
-
exec uvicorn server:app --port "${PORT:-9009}"
|
package/ner/server.py
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
ChatPanel local NER helper — a tiny on-device entity detector for PII redaction.
|
|
3
|
-
|
|
4
|
-
ChatPanel's Privacy → "AI detection" can point at any LOCAL service that accepts
|
|
5
|
-
POST {"text": "..."}
|
|
6
|
-
and returns
|
|
7
|
-
{"entities": [{"value": "...", "type": "PERSON|ORG|GPE|EMAIL|PHONE|..."}]}
|
|
8
|
-
|
|
9
|
-
This wraps spaCy (people / organizations / locations) AND adds a regex pass for
|
|
10
|
-
the structured identifiers spaCy doesn't emit (emails, phone numbers, SSNs, cards,
|
|
11
|
-
IPs), so the detector is comprehensive on its own. Only the redacted placeholders
|
|
12
|
-
(e.g. [[PERSON_1]]) ever reach the chat model — the raw text never leaves your box.
|
|
13
|
-
|
|
14
|
-
--------------------------------------------------------------------------------
|
|
15
|
-
Setup (most machines block global pip installs, so use a virtual env)
|
|
16
|
-
|
|
17
|
-
python3 -m venv .venv
|
|
18
|
-
source .venv/bin/activate # Windows: .venv\\Scripts\\activate
|
|
19
|
-
pip install -r requirements.txt
|
|
20
|
-
python -m spacy download en_core_web_sm
|
|
21
|
-
|
|
22
|
-
Run (the file is server.py, so the uvicorn import path is "server:app")
|
|
23
|
-
|
|
24
|
-
uvicorn server:app --port 9009
|
|
25
|
-
|
|
26
|
-
Then in ChatPanel → Settings → Privacy:
|
|
27
|
-
Redaction : On — + AI detection
|
|
28
|
-
Detector : Local NER service (spaCy / Presidio)
|
|
29
|
-
URL : http://127.0.0.1:9009/ner
|
|
30
|
-
|
|
31
|
-
Tip: en_core_web_sm is small + fast. For better accuracy use en_core_web_md or
|
|
32
|
-
en_core_web_trf (download the same way, then change the load below).
|
|
33
|
-
--------------------------------------------------------------------------------
|
|
34
|
-
"""
|
|
35
|
-
from fastapi import FastAPI
|
|
36
|
-
|
|
37
|
-
import re
|
|
38
|
-
import spacy
|
|
39
|
-
|
|
40
|
-
MODEL = "en_core_web_sm"
|
|
41
|
-
nlp = spacy.load(MODEL)
|
|
42
|
-
|
|
43
|
-
app = FastAPI(title="ChatPanel NER helper")
|
|
44
|
-
|
|
45
|
-
# spaCy's NER emits names / orgs / locations but NOT structured identifiers, so add
|
|
46
|
-
# a regex pass for those. (ChatPanel also catches these on-device, but emitting them
|
|
47
|
-
# here keeps the detector self-contained — what you test is what you get.) Order
|
|
48
|
-
# matters: more specific patterns run first so a card / SSN isn't re-matched as a
|
|
49
|
-
# phone number.
|
|
50
|
-
_PATTERNS = [
|
|
51
|
-
("EMAIL", re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")),
|
|
52
|
-
("SSN", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
|
|
53
|
-
("CREDIT_CARD", re.compile(r"\b(?:\d[ -]?){13,19}\b")),
|
|
54
|
-
("IP", re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b")),
|
|
55
|
-
("PHONE", re.compile(r"(?<!\d)(?:\+?\d{1,2}[ .\-]?)?\(?\d{3}\)?[ .\-]?\d{3}[ .\-]?\d{4}(?!\d)")),
|
|
56
|
-
]
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _regex_entities(text):
|
|
60
|
-
out, taken = [], []
|
|
61
|
-
for label, rx in _PATTERNS:
|
|
62
|
-
for m in rx.finditer(text):
|
|
63
|
-
start, end = m.start(), m.end()
|
|
64
|
-
if any(start < te and ts < end for ts, te in taken):
|
|
65
|
-
continue # span already claimed by a more specific pattern
|
|
66
|
-
taken.append((start, end))
|
|
67
|
-
out.append({"value": m.group(0).strip(), "type": label})
|
|
68
|
-
return out
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
@app.get("/health")
|
|
72
|
-
def health():
|
|
73
|
-
return {"ok": True, "model": MODEL}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
@app.post("/ner")
|
|
77
|
-
def ner(payload: dict):
|
|
78
|
-
"""Return spaCy entities + regex identifiers in ChatPanel's expected shape.
|
|
79
|
-
|
|
80
|
-
ChatPanel maps common labels itself (PER→PERSON, GPE→LOCATION, …) and keeps
|
|
81
|
-
only the categories you enable in the Privacy tab, so it's fine to return all
|
|
82
|
-
of them here.
|
|
83
|
-
"""
|
|
84
|
-
text = (payload or {}).get("text", "") or ""
|
|
85
|
-
doc = nlp(text)
|
|
86
|
-
ents = [{"value": ent.text, "type": ent.label_} for ent in doc.ents]
|
|
87
|
-
ents.extend(_regex_entities(text))
|
|
88
|
-
# De-dup identical value+type (spaCy and a regex can both surface the same span).
|
|
89
|
-
seen, out = set(), []
|
|
90
|
-
for e in ents:
|
|
91
|
-
key = (e["type"], e["value"].lower())
|
|
92
|
-
if e["value"] and key not in seen:
|
|
93
|
-
seen.add(key)
|
|
94
|
-
out.append(e)
|
|
95
|
-
return {"entities": out}
|
package/src/ner-assets.js
DELETED
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
// GENERATED by scripts/gen-ner-assets.mjs — do NOT edit. Edit ner/* and re-run.
|
|
2
|
-
// Base64 of the bundled spaCy NER server, embedded so a standalone binary can
|
|
3
|
-
// write a runnable ner/ directory to disk (the binary has no ner/ on the FS).
|
|
4
|
-
export const NER_ASSETS = {
|
|
5
|
-
"run.sh": "IyEvdXNyL2Jpbi9lbnYgYmFzaAojIE9uZS1zaG90OiBjcmVhdGUgdGhlIHZlbnYgKGlmIG5lZWRlZCksIGluc3RhbGwgZGVwcyArIHRoZSBzcGFDeSBtb2RlbCwgYW5kIHNlcnZlLgojIFVzYWdlOiAgLi9ydW4uc2ggICAgICAgIChkZWZhdWx0cyB0byBwb3J0IDkwMDkpCiMgICAgICAgICBQT1JUPTkxMDAgLi9ydW4uc2gKc2V0IC1ldW8gcGlwZWZhaWwKY2QgIiQoZGlybmFtZSAiJDAiKSIKCmlmIFsgISAtZCAudmVudiBdOyB0aGVuCiAgZWNobyAi4oaSIGNyZWF0aW5nIHZpcnR1YWwgZW52ICgudmVudinigKYiCiAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CmZpCiMgc2hlbGxjaGVjayBkaXNhYmxlPVNDMTA5MQpzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlCgplY2hvICLihpIgaW5zdGFsbGluZyBkZXBlbmRlbmNpZXPigKYiCnBpcCBpbnN0YWxsIC1xIC0tdXBncmFkZSBwaXAKcGlwIGluc3RhbGwgLXEgLXIgcmVxdWlyZW1lbnRzLnR4dApweXRob24gLWMgImltcG9ydCBlbl9jb3JlX3dlYl9zbSIgPi9kZXYvbnVsbCAyPiYxIHx8IHB5dGhvbiAtbSBzcGFjeSBkb3dubG9hZCBlbl9jb3JlX3dlYl9zbQoKZWNobyAi4oaSIHNlcnZpbmcgb24gaHR0cDovLzEyNy4wLjAuMToke1BPUlQ6LTkwMDl9L25lciAgKEN0cmwtQyB0byBzdG9wKSIKZXhlYyB1dmljb3JuIHNlcnZlcjphcHAgLS1wb3J0ICIke1BPUlQ6LTkwMDl9Igo=",
|
|
6
|
-
"server.py": "IiIiCkNoYXRQYW5lbCBsb2NhbCBORVIgaGVscGVyIOKAlCBhIHRpbnkgb24tZGV2aWNlIGVudGl0eSBkZXRlY3RvciBmb3IgUElJIHJlZGFjdGlvbi4KCkNoYXRQYW5lbCdzIFByaXZhY3kg4oaSICJBSSBkZXRlY3Rpb24iIGNhbiBwb2ludCBhdCBhbnkgTE9DQUwgc2VydmljZSB0aGF0IGFjY2VwdHMKICAgIFBPU1QgeyJ0ZXh0IjogIi4uLiJ9CmFuZCByZXR1cm5zCiAgICB7ImVudGl0aWVzIjogW3sidmFsdWUiOiAiLi4uIiwgInR5cGUiOiAiUEVSU09OfE9SR3xHUEV8RU1BSUx8UEhPTkV8Li4uIn1dfQoKVGhpcyB3cmFwcyBzcGFDeSAocGVvcGxlIC8gb3JnYW5pemF0aW9ucyAvIGxvY2F0aW9ucykgQU5EIGFkZHMgYSByZWdleCBwYXNzIGZvcgp0aGUgc3RydWN0dXJlZCBpZGVudGlmaWVycyBzcGFDeSBkb2Vzbid0IGVtaXQgKGVtYWlscywgcGhvbmUgbnVtYmVycywgU1NOcywgY2FyZHMsCklQcyksIHNvIHRoZSBkZXRlY3RvciBpcyBjb21wcmVoZW5zaXZlIG9uIGl0cyBvd24uIE9ubHkgdGhlIHJlZGFjdGVkIHBsYWNlaG9sZGVycwooZS5nLiBbW1BFUlNPTl8xXV0pIGV2ZXIgcmVhY2ggdGhlIGNoYXQgbW9kZWwg4oCUIHRoZSByYXcgdGV4dCBuZXZlciBsZWF2ZXMgeW91ciBib3guCgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQpTZXR1cCAgKG1vc3QgbWFjaGluZXMgYmxvY2sgZ2xvYmFsIHBpcCBpbnN0YWxscywgc28gdXNlIGEgdmlydHVhbCBlbnYpCgogICAgcHl0aG9uMyAtbSB2ZW52IC52ZW52CiAgICBzb3VyY2UgLnZlbnYvYmluL2FjdGl2YXRlICAgICAgICAgICMgV2luZG93czogLnZlbnZcXFNjcmlwdHNcXGFjdGl2YXRlCiAgICBwaXAgaW5zdGFsbCAtciByZXF1aXJlbWVudHMudHh0CiAgICBweXRob24gLW0gc3BhY3kgZG93bmxvYWQgZW5fY29yZV93ZWJfc20KClJ1biAgKHRoZSBmaWxlIGlzIHNlcnZlci5weSwgc28gdGhlIHV2aWNvcm4gaW1wb3J0IHBhdGggaXMgInNlcnZlcjphcHAiKQoKICAgIHV2aWNvcm4gc2VydmVyOmFwcCAtLXBvcnQgOTAwOQoKVGhlbiBpbiBDaGF0UGFuZWwg4oaSIFNldHRpbmdzIOKGkiBQcml2YWN5OgogICAgUmVkYWN0aW9uIDogT24g4oCUICsgQUkgZGV0ZWN0aW9uCiAgICBEZXRlY3RvciAgOiBMb2NhbCBORVIgc2VydmljZSAoc3BhQ3kgLyBQcmVzaWRpbykKICAgIFVSTCAgICAgICA6IGh0dHA6Ly8xMjcuMC4wLjE6OTAwOS9uZXIKClRpcDogZW5fY29yZV93ZWJfc20gaXMgc21hbGwgKyBmYXN0LiBGb3IgYmV0dGVyIGFjY3VyYWN5IHVzZSBlbl9jb3JlX3dlYl9tZCBvcgplbl9jb3JlX3dlYl90cmYgKGRvd25sb2FkIHRoZSBzYW1lIHdheSwgdGhlbiBjaGFuZ2UgdGhlIGxvYWQgYmVsb3cpLgotLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLS0tLQoiIiIKZnJvbSBmYXN0YXBpIGltcG9ydCBGYXN0QVBJCgppbXBvcnQgcmUKaW1wb3J0IHNwYWN5CgpNT0RFTCA9ICJlbl9jb3JlX3dlYl9zbSIKbmxwID0gc3BhY3kubG9hZChNT0RFTCkKCmFwcCA9IEZhc3RBUEkodGl0bGU9IkNoYXRQYW5lbCBORVIgaGVscGVyIikKCiMgc3BhQ3kncyBORVIgZW1pdHMgbmFtZXMgLyBvcmdzIC8gbG9jYXRpb25zIGJ1dCBOT1Qgc3RydWN0dXJlZCBpZGVudGlmaWVycywgc28gYWRkCiMgYSByZWdleCBwYXNzIGZvciB0aG9zZS4gKENoYXRQYW5lbCBhbHNvIGNhdGNoZXMgdGhlc2Ugb24tZGV2aWNlLCBidXQgZW1pdHRpbmcgdGhlbQojIGhlcmUga2VlcHMgdGhlIGRldGVjdG9yIHNlbGYtY29udGFpbmVkIOKAlCB3aGF0IHlvdSB0ZXN0IGlzIHdoYXQgeW91IGdldC4pIE9yZGVyCiMgbWF0dGVyczogbW9yZSBzcGVjaWZpYyBwYXR0ZXJucyBydW4gZmlyc3Qgc28gYSBjYXJkIC8gU1NOIGlzbid0IHJlLW1hdGNoZWQgYXMgYQojIHBob25lIG51bWJlci4KX1BBVFRFUk5TID0gWwogICAgKCJFTUFJTCIsIHJlLmNvbXBpbGUociJbQS1aYS16MC05Ll8lK1wtXStAW0EtWmEtejAtOS5cLV0rXC5bQS1aYS16XXsyLH0iKSksCiAgICAoIlNTTiIsIHJlLmNvbXBpbGUociJcYlxkezN9LVxkezJ9LVxkezR9XGIiKSksCiAgICAoIkNSRURJVF9DQVJEIiwgcmUuY29tcGlsZShyIlxiKD86XGRbIC1dPyl7MTMsMTl9XGIiKSksCiAgICAoIklQIiwgcmUuY29tcGlsZShyIlxiKD86XGR7MSwzfVwuKXszfVxkezEsM31cYiIpKSwKICAgICgiUEhPTkUiLCByZS5jb21waWxlKHIiKD88IVxkKSg/OlwrP1xkezEsMn1bIC5cLV0/KT9cKD9cZHszfVwpP1sgLlwtXT9cZHszfVsgLlwtXT9cZHs0fSg/IVxkKSIpKSwKXQoKCmRlZiBfcmVnZXhfZW50aXRpZXModGV4dCk6CiAgICBvdXQsIHRha2VuID0gW10sIFtdCiAgICBmb3IgbGFiZWwsIHJ4IGluIF9QQVRURVJOUzoKICAgICAgICBmb3IgbSBpbiByeC5maW5kaXRlcih0ZXh0KToKICAgICAgICAgICAgc3RhcnQsIGVuZCA9IG0uc3RhcnQoKSwgbS5lbmQoKQogICAgICAgICAgICBpZiBhbnkoc3RhcnQgPCB0ZSBhbmQgdHMgPCBlbmQgZm9yIHRzLCB0ZSBpbiB0YWtlbik6CiAgICAgICAgICAgICAgICBjb250aW51ZSAgIyBzcGFuIGFscmVhZHkgY2xhaW1lZCBieSBhIG1vcmUgc3BlY2lmaWMgcGF0dGVybgogICAgICAgICAgICB0YWtlbi5hcHBlbmQoKHN0YXJ0LCBlbmQpKQogICAgICAgICAgICBvdXQuYXBwZW5kKHsidmFsdWUiOiBtLmdyb3VwKDApLnN0cmlwKCksICJ0eXBlIjogbGFiZWx9KQogICAgcmV0dXJuIG91dAoKCkBhcHAuZ2V0KCIvaGVhbHRoIikKZGVmIGhlYWx0aCgpOgogICAgcmV0dXJuIHsib2siOiBUcnVlLCAibW9kZWwiOiBNT0RFTH0KCgpAYXBwLnBvc3QoIi9uZXIiKQpkZWYgbmVyKHBheWxvYWQ6IGRpY3QpOgogICAgIiIiUmV0dXJuIHNwYUN5IGVudGl0aWVzICsgcmVnZXggaWRlbnRpZmllcnMgaW4gQ2hhdFBhbmVsJ3MgZXhwZWN0ZWQgc2hhcGUuCgogICAgQ2hhdFBhbmVsIG1hcHMgY29tbW9uIGxhYmVscyBpdHNlbGYgKFBFUuKGklBFUlNPTiwgR1BF4oaSTE9DQVRJT04sIOKApikgYW5kIGtlZXBzCiAgICBvbmx5IHRoZSBjYXRlZ29yaWVzIHlvdSBlbmFibGUgaW4gdGhlIFByaXZhY3kgdGFiLCBzbyBpdCdzIGZpbmUgdG8gcmV0dXJuIGFsbAogICAgb2YgdGhlbSBoZXJlLgogICAgIiIiCiAgICB0ZXh0ID0gKHBheWxvYWQgb3Ige30pLmdldCgidGV4dCIsICIiKSBvciAiIgogICAgZG9jID0gbmxwKHRleHQpCiAgICBlbnRzID0gW3sidmFsdWUiOiBlbnQudGV4dCwgInR5cGUiOiBlbnQubGFiZWxffSBmb3IgZW50IGluIGRvYy5lbnRzXQogICAgZW50cy5leHRlbmQoX3JlZ2V4X2VudGl0aWVzKHRleHQpKQogICAgIyBEZS1kdXAgaWRlbnRpY2FsIHZhbHVlK3R5cGUgKHNwYUN5IGFuZCBhIHJlZ2V4IGNhbiBib3RoIHN1cmZhY2UgdGhlIHNhbWUgc3BhbikuCiAgICBzZWVuLCBvdXQgPSBzZXQoKSwgW10KICAgIGZvciBlIGluIGVudHM6CiAgICAgICAga2V5ID0gKGVbInR5cGUiXSwgZVsidmFsdWUiXS5sb3dlcigpKQogICAgICAgIGlmIGVbInZhbHVlIl0gYW5kIGtleSBub3QgaW4gc2VlbjoKICAgICAgICAgICAgc2Vlbi5hZGQoa2V5KQogICAgICAgICAgICBvdXQuYXBwZW5kKGUpCiAgICByZXR1cm4geyJlbnRpdGllcyI6IG91dH0K",
|
|
7
|
-
"requirements.txt": "ZmFzdGFwaQp1dmljb3JuW3N0YW5kYXJkXQpzcGFjeQo=",
|
|
8
|
-
};
|