@chatpanel/gateway 0.6.45 → 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 +63 -5
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 */ }
|
|
@@ -429,6 +432,24 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
|
|
|
429
432
|
|
|
430
433
|
// ---- backend: api ----------------------------------------------------------
|
|
431
434
|
|
|
435
|
+
// Join a destination's base URL to the incoming path WITHOUT doubling the API version.
|
|
436
|
+
//
|
|
437
|
+
// Every OpenAI-compatible provider tells you to paste a base that already ends at the version
|
|
438
|
+
// — https://integrate.api.nvidia.com/v1, https://openrouter.ai/api/v1, https://router.hugging
|
|
439
|
+
// face.co/v1 — and the request arriving here carries the version too (/v1/chat/completions).
|
|
440
|
+
// Concatenating them produced /v1/v1/chat/completions, and what came back was the provider's
|
|
441
|
+
// own "404 page not found". That reads like a broken gateway, or a wrong model, or a dead
|
|
442
|
+
// channel — anything except the mis-joined URL it actually was.
|
|
443
|
+
//
|
|
444
|
+
// Matching on the leading segment rather than hardcoding "v1" so a provider on /v2 or a beta
|
|
445
|
+
// path is joined correctly too.
|
|
446
|
+
export function joinUpstream(base, pathname, search = '') {
|
|
447
|
+
const b = String(base || '').replace(/\/+$/, '');
|
|
448
|
+
const seg = String(pathname || '').split('/')[1];
|
|
449
|
+
if (seg && b.endsWith(`/${seg}`)) return b.slice(0, -(seg.length + 1)) + pathname + search;
|
|
450
|
+
return b + pathname + search;
|
|
451
|
+
}
|
|
452
|
+
|
|
432
453
|
async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
|
|
433
454
|
let upstream;
|
|
434
455
|
const up0 = trace ? trace.clock() : 0;
|
|
@@ -443,7 +464,7 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
|
|
|
443
464
|
// SSRF guard on the config-supplied upstream: block cloud-metadata + non-http(s)
|
|
444
465
|
// BEFORE the fetch. Loopback/LAN stay allowed (Ollama/LM Studio/homelab are the
|
|
445
466
|
// point of a BYO gateway); only the credential-theft pivot is refused.
|
|
446
|
-
const upstreamUrl = assertEndpointUrl(base
|
|
467
|
+
const upstreamUrl = assertEndpointUrl(joinUpstream(base, pathname, search)).toString();
|
|
447
468
|
upstream = await fetch(upstreamUrl, {
|
|
448
469
|
method: req.method,
|
|
449
470
|
headers,
|
|
@@ -1150,7 +1171,44 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
1150
1171
|
|
|
1151
1172
|
// Route by the requested model → a destination (agent via the bridge, or an
|
|
1152
1173
|
// API we forward to). Falls back to the legacy backend when none configured.
|
|
1153
|
-
|
|
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
|
+
}
|
|
1154
1212
|
if (trace) {
|
|
1155
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) };
|
|
1156
1214
|
}
|