@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.46",
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.46';
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
- if (!HOP_BY_HOP.has(k.toLowerCase())) out[k] = v;
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
- const dest = resolveDestination(body?.model, cfg, r.kind);
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
  }