@chatpanel/gateway 0.6.69 → 0.6.71

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.69",
3
+ "version": "0.6.71",
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/bridge.js CHANGED
@@ -17,6 +17,47 @@ import os from 'node:os';
17
17
 
18
18
  const DEFAULT_TOKEN_PATH = join(os.homedir(), '.chatpanel', 'bridge-token');
19
19
 
20
+ export const DEFAULT_BRIDGE_URL = 'http://127.0.0.1:4319';
21
+ const BRIDGE_PROBE_TTL_MS = 10_000;
22
+ let bridgeResolved = { at: 0, cfgUrl: '', url: '', fell: false };
23
+
24
+ async function bridgeAnswers(url, timeoutMs) {
25
+ try {
26
+ const r = await fetch(`${url}/health`, { signal: AbortSignal.timeout(timeoutMs) });
27
+ return r.ok;
28
+ } catch { return false; }
29
+ }
30
+
31
+ /**
32
+ * Where the bridge is, checked rather than believed.
33
+ *
34
+ * The persisted bridge.url was found pointing at an ephemeral port nothing answered on,
35
+ * while the real bridge sat on 4319: every agent showed unavailable, every agent turn failed,
36
+ * and the startup line said so in a log nobody was reading. A configured address that does
37
+ * not answer while the default one does is a stale setting, not a decision, so the default
38
+ * is used and the fall-back is logged. Probed at most every ten seconds.
39
+ */
40
+ export async function resolveBridgeUrl(cfg, { fallback, timeoutMs = 1500, now = Date.now() } = {}) {
41
+ // CHATPANEL_BRIDGE_FALLBACK=off disables the fallback: the test suite sets it, because a
42
+ // test whose fake bridge has gone away would otherwise find the developer's REAL bridge on
43
+ // 4319 and send its turns to a live coding agent.
44
+ if (fallback === undefined) fallback = process.env.CHATPANEL_BRIDGE_FALLBACK === 'off' ? '' : DEFAULT_BRIDGE_URL;
45
+ const cfgUrl = String(cfg?.bridge?.url || '').replace(/\/$/, '');
46
+ if (bridgeResolved.url && bridgeResolved.cfgUrl === cfgUrl && now - bridgeResolved.at < BRIDGE_PROBE_TTL_MS) return bridgeResolved.url;
47
+ let url = cfgUrl || fallback;
48
+ let fell = false;
49
+ if (fallback && url !== fallback && !(await bridgeAnswers(url, timeoutMs)) && await bridgeAnswers(fallback, timeoutMs)) {
50
+ url = fallback;
51
+ fell = true;
52
+ if (!bridgeResolved.fell || bridgeResolved.cfgUrl !== cfgUrl) console.log(`[gateway] bridge.url ${cfgUrl} is not answering; the bridge on ${fallback} is — using it (fix the address in Settings to silence this)`);
53
+ }
54
+ bridgeResolved = { at: now, cfgUrl, url, fell };
55
+ return url;
56
+ }
57
+
58
+ /** Test seam. */
59
+ export function resetBridgeResolution() { bridgeResolved = { at: 0, cfgUrl: '', url: '', fell: false }; }
60
+
20
61
  export function readBridgeToken(cfgToken, tokenPath = DEFAULT_TOKEN_PATH) {
21
62
  if (cfgToken) return cfgToken;
22
63
  try {
@@ -83,6 +83,21 @@ export function applyNerModelSelection(cfg, id) {
83
83
  }
84
84
 
85
85
  // Merge an editable patch into the live cfg. Only known fields; ignores the rest.
86
+ /**
87
+ * Is this API destination the gateway itself? A loop: a turn routed there comes straight
88
+ * back in. It arrived once from a backup import — the extension keeps the gateway in its own
89
+ * endpoint list, and the desktop copied that list into the gateway's destinations — and its
90
+ * model ids (the agents') then shadowed the real agents. Refused at the door, with a log line.
91
+ */
92
+ export function isSelfDestination(d, cfg = {}) {
93
+ if (!d || d.type !== 'api') return false;
94
+ let u;
95
+ try { u = new URL(String(d.baseUrl || '')); } catch { return false; }
96
+ const loop = /^(127\.0\.0\.1|localhost|\[::1\]|0\.0\.0\.0)$/i.test(u.hostname);
97
+ const port = Number(u.port) || (u.protocol === 'https:' ? 443 : 80);
98
+ return loop && port === (Number(cfg.port) || 4320);
99
+ }
100
+
86
101
  export function applyConfigPatch(cfg, patch = {}) {
87
102
  if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
88
103
  if (Array.isArray(patch.destinations)) {
@@ -91,6 +106,7 @@ export function applyConfigPatch(cfg, patch = {}) {
91
106
  const prev = new Map((Array.isArray(cfg.destinations) ? cfg.destinations : []).map((d) => [d.id, d]));
92
107
  cfg.destinations = patch.destinations
93
108
  .filter((d) => d && typeof d.id === 'string' && (d.type === 'agent' || d.type === 'api'))
109
+ .filter((d) => { const self = isSelfDestination(d, cfg); if (self) console.log(`[gateway] refusing destination "${d.id}": ${d.baseUrl} is this gateway — a loop`); return !self; })
94
110
  .map((d) => {
95
111
  const out = { id: d.id, type: d.type, models: Array.isArray(d.models) ? d.models.filter((m) => typeof m === 'string' && m) : [] };
96
112
  if (d.type === 'agent') out.agent = d.agent || d.id;
package/src/router.js CHANGED
@@ -12,7 +12,7 @@
12
12
  // }
13
13
 
14
14
  import { secureFetch } from './secure-fetch.js';
15
- import { readBridgeToken } from './bridge.js';
15
+ import { readBridgeToken, resolveBridgeUrl } from './bridge.js';
16
16
  //
17
17
  // /v1/models aggregates every destination's models so clients can discover them.
18
18
 
@@ -29,10 +29,15 @@ const KNOWN_AGENTS = ['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity'
29
29
  export function listDestinations(cfg) {
30
30
  const configured = (Array.isArray(cfg.destinations) ? cfg.destinations : []).filter(Boolean);
31
31
  const haveId = new Set(configured.map((d) => d.id));
32
- const out = [...configured];
33
- for (const a of KNOWN_AGENTS) {
34
- if (!haveId.has(a)) out.push({ id: a, type: 'agent', agent: a, models: [a] });
35
- }
32
+ // AGENTS FIRST. The model list de-duplicates by id in this order, and resolveDestination
33
+ // takes the first destination that serves a model so an API destination that happened to
34
+ // list "codex" or "claude" (a gateway pointed at itself did exactly that) used to swallow
35
+ // the agents: they vanished from the picker and a turn for one was proxied to the loop.
36
+ const out = [
37
+ ...configured.filter((d) => d.type === 'agent'),
38
+ ...KNOWN_AGENTS.filter((a) => !haveId.has(a)).map((a) => ({ id: a, type: 'agent', agent: a, models: [a] })),
39
+ ...configured.filter((d) => d.type !== 'agent'),
40
+ ];
36
41
  if (cfg.backend === 'api' && !configured.some((d) => d.type === 'api')) {
37
42
  out.push({ id: 'openai', type: 'api', protocol: 'openai', baseUrl: cfg.upstreams?.openai?.baseUrl, models: [] });
38
43
  out.push({ id: 'anthropic', type: 'api', protocol: 'anthropic', baseUrl: cfg.upstreams?.anthropic?.baseUrl, models: [] });
@@ -156,7 +161,7 @@ export function aggregateModels(cfg) {
156
161
  * spends a subprocess per agent to describe something unusable.
157
162
  */
158
163
  async function bridgeAgentModels(cfg, installed, timeoutMs) {
159
- const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
164
+ const base = await resolveBridgeUrl(cfg);
160
165
  if (!base || !installed) return new Map();
161
166
  const token = readBridgeToken(cfg.bridge?.token);
162
167
  const ids = [...installed.entries()].filter(([, ok]) => ok).map(([id]) => id);
@@ -183,7 +188,7 @@ async function bridgeAgentModels(cfg, installed, timeoutMs) {
183
188
 
184
189
  /** id → installed, from the bridge's own /health. `null` when it could not be asked. */
185
190
  async function bridgeAgentAvailability(cfg, timeoutMs) {
186
- const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
191
+ const base = await resolveBridgeUrl(cfg);
187
192
  if (!base) return null;
188
193
  try {
189
194
  const token = readBridgeToken(cfg.bridge?.token);
package/src/server.js CHANGED
@@ -24,6 +24,7 @@ import { redactSegments, segment } from './redact.js';
24
24
  import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer, restoreDeep } from './stream.js';
25
25
  import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote, assertEndpointUrl } from '@chatpanel/pii';
26
26
  import { ensureGatewayToken, isAdminAuthorized } from './gateway-token.js';
27
+ import { resolveBridgeUrl } from './bridge.js';
27
28
  import { secureFetch } from './secure-fetch.js';
28
29
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
29
30
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
@@ -55,7 +56,7 @@ import * as openai from './openai.js';
55
56
  import * as responses from './responses.js';
56
57
  import * as anthropic from './anthropic.js';
57
58
 
58
- export const VERSION = '0.6.69';
59
+ export const VERSION = '0.6.71';
59
60
 
60
61
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
61
62
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -356,13 +357,14 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
356
357
  // Full tier for everyone here (the free allowance is enforced in the main
357
358
  // handler), but the custom dictionary stays capped for free.
358
359
  const redactOpts = { tier: cfg.redaction.tier === 'full' ? 'full' : 'basic', dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
359
- const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
360
+ const bridgeUrl = await resolveBridgeUrl(cfg);
361
+ const s = createRelaySession({ vault, redactOpts, bridgeUrl, token, harness });
360
362
  const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
361
363
  // The placeholder note is already in `system` (injected into the body after
362
364
  // redaction in the main handler), so toTurn() carried it here — nothing to add.
363
365
  let resp;
364
366
  try {
365
- resp = await openBridgeChat({ bridgeUrl: cfg.bridge.url, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
367
+ resp = await openBridgeChat({ bridgeUrl, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
366
368
  } catch (e) { clearTimeout(ttl); endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
367
369
  s.reader = resp.body.getReader();
368
370
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
@@ -420,7 +422,7 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
420
422
  // agent id, which leaves the agent on its own default exactly as before.
421
423
  const { agentModel } = parseAgentModel(body?.model, cfg);
422
424
  const turn = {
423
- bridgeUrl: cfg.bridge.url, agent, token, messages, system, signal: ac.signal,
425
+ bridgeUrl: await resolveBridgeUrl(cfg), agent, token, messages, system, signal: ac.signal,
424
426
  ...(agentModel ? { options: { model: agentModel } } : {}),
425
427
  };
426
428
 
@@ -1541,7 +1543,7 @@ export function createGateway(cfg = loadConfig()) {
1541
1543
  // modes for one screen; and the direct-to-bridge path is the one with no policy in front
1542
1544
  // of it, so making it necessary for a feature is how it becomes the habit.
1543
1545
  if (req.method === 'GET' && pathname === '/skills') {
1544
- const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
1546
+ const base = await resolveBridgeUrl(cfg);
1545
1547
  if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
1546
1548
  const token = readBridgeToken(cfg.bridge?.token);
1547
1549
  try {
@@ -1572,7 +1574,7 @@ export function createGateway(cfg = loadConfig()) {
1572
1574
  if (!id || id === '.' || id === '..' || id.includes('/') || id.includes('\\')) {
1573
1575
  return sendJson(res, 400, { error: { message: 'not a skill id', type: 'invalid_request' } });
1574
1576
  }
1575
- const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
1577
+ const base = await resolveBridgeUrl(cfg);
1576
1578
  if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
1577
1579
  const token = readBridgeToken(cfg.bridge?.token);
1578
1580
  try {
package/src/stt-engine.js CHANGED
@@ -422,6 +422,18 @@ async function decodeSession(s, { flush = false } = {}) {
422
422
  const tooLong = audio.length >= MAX_SEGMENT_S * SAMPLE_RATE;
423
423
  const overflow = audio.length >= MAX_BUFFER_S * SAMPLE_RATE;
424
424
  if (flush || trailingQuiet || tooLong || overflow) {
425
+ // WHY THIS SEGMENT WAS COMMITTED — and it is not always because the speaker finished.
426
+ //
427
+ // Four situations produced an identical `final`, and a client could not tell them apart:
428
+ // the session ending and a trailing pause are the end of a TURN; the length and buffer
429
+ // caps are this engine cutting a segment it cannot hold any longer, mid-sentence, while
430
+ // the speaker is still going. In dictation the difference does not matter — either way
431
+ // the text is appended. In a voice CONVERSATION every final is SENT, so a long sentence
432
+ // was cut at twelve seconds and half of it asked as a question:
433
+ // "…you should just continuously listen to it and then" → sent, answered, lost.
434
+ // Both fields are ADDITIVE: an older client ignores them and behaves exactly as before.
435
+ const reason = flush ? 'flush' : trailingQuiet ? 'silence' : overflow ? 'overflow' : 'length';
436
+ const endOfTurn = reason === 'flush' || reason === 'silence';
425
437
  // Commit: the open segment becomes a final; the buffer restarts empty.
426
438
  s.chunks = []; s.samples = 0; s.lastInterim = '';
427
439
  // Diarize this committed segment (opt-in): embed its audio, cluster → speaker.
@@ -433,7 +445,7 @@ async function decodeSession(s, { flush = false } = {}) {
433
445
  speaker = s.diarizer.assign(vec, { pinnedLabel: s.speakerLabel });
434
446
  } catch { /* diarization is additive — a failure never drops the transcript */ }
435
447
  }
436
- emit(s, speaker ? { type: 'final', text, speaker } : { type: 'final', text });
448
+ emit(s, { type: 'final', text, reason, endOfTurn, ...(speaker ? { speaker } : {}) });
437
449
  } else if (text !== s.lastInterim) {
438
450
  s.lastInterim = text;
439
451
  emit(s, { type: 'interim', text });