@chatpanel/gateway 0.6.46 → 0.6.47
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/package.json +1 -1
- package/src/router.js +5 -1
- package/src/server.js +44 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.47",
|
|
4
4
|
"description": "Local privacy gateway \u2014 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": {
|
package/src/router.js
CHANGED
|
@@ -41,10 +41,14 @@ export function listDestinations(cfg) {
|
|
|
41
41
|
|
|
42
42
|
// Pick the destination that serves `model` (explicit membership → id/agent match →
|
|
43
43
|
// a same-protocol fallback → the first destination).
|
|
44
|
-
export function resolveDestination(model, cfg, kind) {
|
|
44
|
+
export function resolveDestination(model, cfg, kind, { destination = '' } = {}) {
|
|
45
45
|
const dests = listDestinations(cfg);
|
|
46
46
|
const wantsAnthropic = kind === 'anthropic';
|
|
47
47
|
const protoOk = (d) => (wantsAnthropic ? d.protocol === 'anthropic' : d.protocol !== 'anthropic');
|
|
48
|
+
// An explicit destination wins outright and never falls through: the caller named the
|
|
49
|
+
// provider it means, so guessing a different one would be worse than failing. The caller
|
|
50
|
+
// checks that what came back is what it asked for.
|
|
51
|
+
if (destination) return dests.find((d) => d.id === destination) || null;
|
|
48
52
|
return (
|
|
49
53
|
// Explicit: a destination that serves this exact model (a known agent like codex
|
|
50
54
|
// matches its own agent destination here — so it ALWAYS goes to the bridge).
|
package/src/server.js
CHANGED
|
@@ -41,7 +41,7 @@ import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
|
|
|
41
41
|
import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL, STT_DTYPES, isValidDtype } from './stt-models.js';
|
|
42
42
|
import { resolvePro, checkQuota, consume, usage } from './freegate.js';
|
|
43
43
|
import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
|
|
44
|
-
import { resolveDestination, aggregateModelsAsync } from './router.js';
|
|
44
|
+
import { resolveDestination, aggregateModelsAsync, listDestinations } from './router.js';
|
|
45
45
|
import { makeAccessEvent } from './observability.js';
|
|
46
46
|
import { createPersistentAccessLog } from './access-log-store.js';
|
|
47
47
|
import { planQueries, multiSearch } from './rrf.js';
|
|
@@ -49,7 +49,7 @@ import * as openai from './openai.js';
|
|
|
49
49
|
import * as responses from './responses.js';
|
|
50
50
|
import * as anthropic from './anthropic.js';
|
|
51
51
|
|
|
52
|
-
export const VERSION = '0.6.
|
|
52
|
+
export const VERSION = '0.6.47';
|
|
53
53
|
|
|
54
54
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
55
55
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -282,7 +282,10 @@ async function probeNerHealth(cfg) {
|
|
|
282
282
|
function forwardHeaders(headers, base) {
|
|
283
283
|
const out = {};
|
|
284
284
|
for (const [k, v] of Object.entries(headers)) {
|
|
285
|
-
|
|
285
|
+
const lower = k.toLowerCase();
|
|
286
|
+
// ChatPanel's own routing metadata is for THIS hop and is not the provider's business.
|
|
287
|
+
if (lower.startsWith('x-chatpanel-')) continue;
|
|
288
|
+
if (!HOP_BY_HOP.has(lower)) out[k] = v;
|
|
286
289
|
}
|
|
287
290
|
out['accept-encoding'] = 'identity'; // must read plain text to restore tokens
|
|
288
291
|
try { out.host = new URL(base).host; } catch { /* leave unset */ }
|
|
@@ -1168,7 +1171,44 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1168
1171
|
|
|
1169
1172
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
1170
1173
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
1171
|
-
|
|
1174
|
+
// ChatPanel's own routing envelope, never the provider's business. A caller that knows
|
|
1175
|
+
// WHICH destination it means says so here instead of hoping a model id is unique — 39 ids
|
|
1176
|
+
// on a three-provider machine already collide once you ignore case, and two providers
|
|
1177
|
+
// offering the same id exactly is ordinary. Without this, `dests.find(...)` picks whichever
|
|
1178
|
+
// destination happens to come first and the call goes out on the wrong provider's key.
|
|
1179
|
+
// ChatPanel's routing metadata travels in HEADERS, not in the request body.
|
|
1180
|
+
//
|
|
1181
|
+
// It started as a `chatpanel` field on the JSON body, and NVIDIA answered "unsupported
|
|
1182
|
+
// parameters" — OpenAI-compatible providers validate the body strictly and reject fields
|
|
1183
|
+
// they do not know, while ignoring headers they do not know. A body field also breaks
|
|
1184
|
+
// against any gateway older than the one that strips it, which is every gateway already
|
|
1185
|
+
// installed. The body belongs to the provider; this hop gets its own channel.
|
|
1186
|
+
//
|
|
1187
|
+
// The legacy body field is still honoured (and removed) so a client that has not updated
|
|
1188
|
+
// yet keeps working instead of 400ing at the provider.
|
|
1189
|
+
const legacy = (body && typeof body.chatpanel === 'object' && body.chatpanel) || null;
|
|
1190
|
+
if (legacy) {
|
|
1191
|
+
delete body.chatpanel;
|
|
1192
|
+
outBody = Buffer.from(JSON.stringify(body), 'utf8');
|
|
1193
|
+
}
|
|
1194
|
+
const hint = {
|
|
1195
|
+
destination: String(req.headers['x-chatpanel-destination'] || legacy?.destination || '').trim(),
|
|
1196
|
+
reach: String(req.headers['x-chatpanel-reach'] || legacy?.reach || '').trim(),
|
|
1197
|
+
};
|
|
1198
|
+
const dest = resolveDestination(body?.model, cfg, r.kind, { destination: hint.destination });
|
|
1199
|
+
// An EXPLICIT destination that does not resolve is an error, not an invitation to fall
|
|
1200
|
+
// back. Falling back would send a credential-bearing call to a provider the user did not
|
|
1201
|
+
// choose — the silent-misroute version of the bug this field exists to prevent.
|
|
1202
|
+
if (hint.destination && (!dest || dest.id !== hint.destination)) {
|
|
1203
|
+
trace?.commit();
|
|
1204
|
+
return sendJson(res, 404, {
|
|
1205
|
+
error: {
|
|
1206
|
+
message: `no destination "${hint.destination}" is configured on this gateway`,
|
|
1207
|
+
type: 'unknown_destination',
|
|
1208
|
+
known: listDestinations(cfg).map((d) => d.id),
|
|
1209
|
+
},
|
|
1210
|
+
});
|
|
1211
|
+
}
|
|
1172
1212
|
if (trace) {
|
|
1173
1213
|
trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, sanitized: sanitizedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
|
|
1174
1214
|
}
|