@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 +1 -1
- package/src/bridge.js +41 -0
- package/src/configstore.js +16 -0
- package/src/router.js +12 -7
- package/src/server.js +8 -6
- package/src/stt-engine.js +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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 {
|
package/src/configstore.js
CHANGED
|
@@ -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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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 =
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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,
|
|
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 });
|