@chatpanel/gateway 0.6.63 → 0.6.65

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.63",
3
+ "version": "0.6.65",
4
4
  "description": "Local privacy gateway — 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
@@ -67,9 +67,11 @@ export async function openBridgeChat({ bridgeUrl, agent, token, messages, system
67
67
  return res;
68
68
  }
69
69
 
70
- // Stream a turn through the bridge. Calls onText(restorableChunk) for each delta
70
+ // Stream a turn through the bridge. Calls onText(restorableChunk) for each delta, and
71
+ // onActivity(event) for everything else the agent reports — status lines, the working
72
+ // directory, tool calls, reasoning.
71
73
  // of model text and returns the full (un-restored) text. Throws on bridge error.
72
- export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText) {
74
+ export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText, onActivity = null) {
73
75
  const res = await fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
74
76
  method: 'POST',
75
77
  headers: {
@@ -118,8 +120,20 @@ export async function streamBridgeChat({ bridgeUrl, agent, token, messages, syst
118
120
  }
119
121
  } else if (evt.type === 'error') {
120
122
  err = new Error(evt.error || 'bridge error');
123
+ } else if (onActivity) {
124
+ // WHAT THE AGENT IS DOING, for a client that wants to show it.
125
+ //
126
+ // These used to be dropped with a comment calling them "the agent's local side
127
+ // effects". They are — and they are also the ONLY thing that happens for the ten
128
+ // seconds an agent spends reading files before it says a word. A client routed
129
+ // through this gateway saw a spinner and nothing else, while one talking to the
130
+ // bridge directly showed the work; that difference was pushing clients toward the
131
+ // direct path, which is the one with no redaction in it.
132
+ //
133
+ // Passed through as-is. Deciding here which of a coding agent's events are worth
134
+ // showing would be this file guessing at someone's UI.
135
+ onActivity(evt);
121
136
  }
122
- // tool / reasoning / status events are the agent's local side effects — ignore.
123
137
  }
124
138
  };
125
139
 
package/src/router.js CHANGED
@@ -12,6 +12,7 @@
12
12
  // }
13
13
 
14
14
  import { secureFetch } from './secure-fetch.js';
15
+ import { readBridgeToken } from './bridge.js';
15
16
  //
16
17
  // /v1/models aggregates every destination's models so clients can discover them.
17
18
 
@@ -67,22 +68,155 @@ export function resolveDestination(model, cfg, kind, { destination = '' } = {})
67
68
  // Aggregate every destination's models for GET /v1/models. Agents expose their
68
69
  // own name as the model; APIs expose ONLY real model ids (never the destination
69
70
  // id — that's a provider name, not a model).
71
+ /**
72
+ * WHICH API SHAPE A MODEL WANTS TO BE CALLED WITH.
73
+ *
74
+ * `owned_by` names the destination, which is a routing fact, not a calling convention — and
75
+ * a client needs the second one to build a request. Anthropic models take the Messages API;
76
+ * OpenAI-compatible ones take chat/completions (and the Responses API where the destination
77
+ * offers it); an agent takes neither, because the gateway synthesises the response itself
78
+ * from the bridge's stream.
79
+ *
80
+ * Stated here rather than inferred from the id in every client. Guessing from the name is
81
+ * how `claude` the local CLI agent gets called as if it were Anthropic's hosted API.
82
+ */
83
+ function apiShapeOf(d) {
84
+ if (d.type === 'agent') return { api: 'agent', endpoints: ['/v1/chat/completions'] };
85
+ if (d.protocol === 'anthropic') return { api: 'anthropic', endpoints: ['/v1/messages'] };
86
+ return { api: 'openai', endpoints: ['/v1/chat/completions', '/v1/responses'] };
87
+ }
88
+
70
89
  export function aggregateModels(cfg) {
71
90
  const data = [];
72
91
  const seen = new Set();
73
- const add = (id, owner) => { if (id && !seen.has(id)) { seen.add(id); data.push({ id, object: 'model', owned_by: owner }); } };
92
+ const add = (id, owner, d) => {
93
+ if (!id || seen.has(id)) return;
94
+ seen.add(id);
95
+ const shape = apiShapeOf(d);
96
+ data.push({
97
+ id,
98
+ object: 'model',
99
+ owned_by: owner,
100
+ // Additive fields an OpenAI client ignores and a ChatPanel client uses to decide how
101
+ // to call, and to group a picker by provider instead of by a flat list of ids.
102
+ provider: d.id,
103
+ provider_type: d.type === 'agent' ? 'agent' : (d.protocol === 'anthropic' ? 'anthropic' : 'openai'),
104
+ api: shape.api,
105
+ endpoints: shape.endpoints,
106
+ });
107
+ };
74
108
  for (const d of listDestinations(cfg)) {
75
- if (d.type === 'agent') for (const m of (d.models?.length ? d.models : [d.id])) add(m, 'chatpanel-bridge');
76
- else for (const m of (d.models || [])) add(m, d.id);
109
+ if (d.type === 'agent') for (const m of (d.models?.length ? d.models : [d.id])) add(m, 'chatpanel-bridge', d);
110
+ else for (const m of (d.models || [])) add(m, d.id, d);
77
111
  }
78
112
  return { object: 'list', data };
79
113
  }
80
114
 
115
+ /**
116
+ * The models each installed agent can be asked for.
117
+ *
118
+ * A CLI agent is not one model — Claude Code takes opus/sonnet/haiku, others enumerate their
119
+ * own — and listing only the agent id meant a user picked `claude` and got whatever default
120
+ * the CLI had. When that default is newer than the installed CLI, the answer is a version
121
+ * error about a model the user never chose.
122
+ *
123
+ * Asked of the bridge, which is the only thing that knows what each CLI supports, and only
124
+ * for agents that are actually INSTALLED: enumerating models for a CLI that is not there
125
+ * spends a subprocess per agent to describe something unusable.
126
+ */
127
+ async function bridgeAgentModels(cfg, installed, timeoutMs) {
128
+ const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
129
+ if (!base || !installed) return new Map();
130
+ const token = readBridgeToken(cfg.bridge?.token);
131
+ const ids = [...installed.entries()].filter(([, ok]) => ok).map(([id]) => id);
132
+ const out = new Map();
133
+ await Promise.all(ids.map(async (id) => {
134
+ try {
135
+ const res = await fetch(`${base}/list-models`, {
136
+ method: 'POST',
137
+ headers: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
138
+ body: JSON.stringify({ agent: id }),
139
+ signal: AbortSignal.timeout(timeoutMs),
140
+ });
141
+ if (!res.ok) return;
142
+ const body = await res.json();
143
+ const models = (Array.isArray(body?.models) ? body.models : [])
144
+ .map((m) => (typeof m === 'string' ? m : m?.id || m?.name || ''))
145
+ .map((m) => String(m).trim())
146
+ .filter(Boolean);
147
+ if (models.length) out.set(id, models.slice(0, 40));
148
+ } catch { /* an agent that will not enumerate still works under its bare id */ }
149
+ }));
150
+ return out;
151
+ }
152
+
153
+ /** id → installed, from the bridge's own /health. `null` when it could not be asked. */
154
+ async function bridgeAgentAvailability(cfg, timeoutMs) {
155
+ const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
156
+ if (!base) return null;
157
+ try {
158
+ const token = readBridgeToken(cfg.bridge?.token);
159
+ const res = await fetch(`${base}/health`, {
160
+ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
161
+ signal: AbortSignal.timeout(timeoutMs),
162
+ });
163
+ if (!res.ok) return null;
164
+ const body = await res.json();
165
+ if (!Array.isArray(body?.agents)) return null;
166
+ return new Map(body.agents.map((a) => [a.id, !!a.available]));
167
+ } catch {
168
+ return null; // not reachable — say nothing rather than saying "none"
169
+ }
170
+ }
171
+
81
172
  // Async variant: also PROXIES each API destination's own /v1/models to discover
82
173
  // real model ids (using its saved key). Fail-open per destination.
83
174
  export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
84
175
  const base = aggregateModels(cfg);
85
176
  const seen = new Set(base.data.map((m) => m.id));
177
+
178
+ // WHICH AGENTS ARE ACTUALLY ON THIS MACHINE.
179
+ //
180
+ // The list above is the ROUTING TABLE: it names every agent the gateway would route to,
181
+ // whether or not that CLI is installed. On a fresh machine that is a model picker full of
182
+ // names that all fail on first use, which is the worst possible first five minutes.
183
+ //
184
+ // Only the bridge knows what is on disk, so the gateway asks it — once, here — rather than
185
+ // every client asking separately. A client that had to check for itself would need the
186
+ // bridge's address and token as well as ours, and the direct-to-bridge path is the one
187
+ // with no policy in front of it; making it necessary is how it becomes the habit.
188
+ //
189
+ // `available` is left UNDEFINED when the bridge cannot be reached. Absent means "we did not
190
+ // find out", which is not the same as false, and a picker that greys out every agent
191
+ // because one health check timed out is worse than one that says nothing.
192
+ const agentAvailability = await bridgeAgentAvailability(cfg, timeoutMs);
193
+ if (agentAvailability) {
194
+ for (const m of base.data) {
195
+ if (m.owned_by === 'chatpanel-bridge') m.available = agentAvailability.get(m.id) ?? false;
196
+ }
197
+ }
198
+
199
+ // Each installed agent's own models, listed as `agent/model` beside the bare id. The bare
200
+ // id stays and still means "the agent's default", so nothing that already works breaks.
201
+ const agentModels = await bridgeAgentModels(cfg, agentAvailability, timeoutMs);
202
+ for (const [agent, models] of agentModels) {
203
+ const parent = base.data.find((m) => m.id === agent);
204
+ if (!parent) continue;
205
+ for (const model of models) {
206
+ const id = `${agent}/${model}`;
207
+ if (seen.has(id)) continue;
208
+ seen.add(id);
209
+ base.data.push({
210
+ ...parent,
211
+ id,
212
+ // `model` is what the picker shows under the agent's heading; the agent stays the
213
+ // provider, so the grouping puts them together without any id parsing.
214
+ model,
215
+ available: true,
216
+ });
217
+ }
218
+ }
219
+
86
220
  const dests = listDestinations(cfg).filter((d) => d.type === 'api' && d.baseUrl);
87
221
  await Promise.all(dests.map(async (d) => {
88
222
  const ctrl = new AbortController();
@@ -100,7 +234,15 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
100
234
  const list = Array.isArray(j?.data) ? j.data : (Array.isArray(j?.models) ? j.models : []);
101
235
  for (const m of list) {
102
236
  const id = typeof m === 'string' ? m : m?.id;
103
- if (id && !seen.has(id)) { seen.add(id); base.data.push({ id, object: 'model', owned_by: d.id }); }
237
+ if (id && !seen.has(id)) {
238
+ seen.add(id);
239
+ const shape = apiShapeOf(d);
240
+ base.data.push({
241
+ id, object: 'model', owned_by: d.id, provider: d.id,
242
+ provider_type: d.protocol === 'anthropic' ? 'anthropic' : 'openai',
243
+ api: shape.api, endpoints: shape.endpoints,
244
+ });
245
+ }
104
246
  }
105
247
  } catch { /* fail-open */ } finally { clearTimeout(t); }
106
248
  }));
package/src/server.js CHANGED
@@ -21,7 +21,7 @@ import { createServer } from 'node:http';
21
21
  import { loadConfig } from './config.js';
22
22
  import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.js';
23
23
  import { redactSegments, segment } from './redact.js';
24
- import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
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
27
  import { secureFetch } from './secure-fetch.js';
@@ -55,7 +55,7 @@ import * as openai from './openai.js';
55
55
  import * as responses from './responses.js';
56
56
  import * as anthropic from './anthropic.js';
57
57
 
58
- export const VERSION = '0.6.63';
58
+ export const VERSION = '0.6.65';
59
59
 
60
60
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
61
61
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -204,8 +204,31 @@ function route(pathname, headers, cfg) {
204
204
  return { kind: 'openai', adapter: openai, redactable: openai.matches(pathname), base: cfg.upstreams?.openai?.baseUrl };
205
205
  }
206
206
 
207
+ /**
208
+ * An agent id, and the model to run it with.
209
+ *
210
+ * A CLI agent is not one model. Claude Code takes `opus`, `sonnet` or `haiku`; the others
211
+ * have their own lists. Picking `claude` alone leaves the CLI on whatever default it has,
212
+ * which is how a user gets "this version does not support that model" from a tool they never
213
+ * chose a model for. So `claude/opus` names both, and the slash is the only new syntax.
214
+ *
215
+ * A bare agent id still works and still means "the agent's own default" — every client that
216
+ * predates this keeps working, which is the whole reason the model is a SUFFIX rather than a
217
+ * change to the id.
218
+ */
219
+ export function parseAgentModel(model, cfg) {
220
+ const raw = String(model || '');
221
+ if (KNOWN_AGENTS.has(raw)) return { agent: raw, agentModel: '' };
222
+ const slash = raw.indexOf('/');
223
+ if (slash > 0) {
224
+ const head = raw.slice(0, slash);
225
+ if (KNOWN_AGENTS.has(head)) return { agent: head, agentModel: raw.slice(slash + 1) };
226
+ }
227
+ return { agent: cfg.bridge.agent, agentModel: '' };
228
+ }
229
+
207
230
  function pickAgent(model, cfg) {
208
- return KNOWN_AGENTS.has(model) ? model : cfg.bridge.agent;
231
+ return parseAgentModel(model, cfg).agent;
209
232
  }
210
233
 
211
234
  // A follow-up request carrying a tool result for a PARKED relay session. Such a
@@ -393,7 +416,13 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
393
416
  const ac = new AbortController();
394
417
  req.on('close', () => ac.abort());
395
418
 
396
- const turn = { bridgeUrl: cfg.bridge.url, agent, token, messages, system, signal: ac.signal };
419
+ // The model half of `claude/opus`, handed to the CLI as its `--model`. Absent for a bare
420
+ // agent id, which leaves the agent on its own default exactly as before.
421
+ const { agentModel } = parseAgentModel(body?.model, cfg);
422
+ const turn = {
423
+ bridgeUrl: cfg.bridge.url, agent, token, messages, system, signal: ac.signal,
424
+ ...(agentModel ? { options: { model: agentModel } } : {}),
425
+ };
397
426
 
398
427
  if (!wantStream) {
399
428
  try {
@@ -424,7 +453,19 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
424
453
  if (trace && first) { trace.lap('upstream', up0); sStart = trace.clock(); first = false; } // time-to-first-token
425
454
  const restored = restorer.push(chunk);
426
455
  if (restored) res.write(shaper.sseDelta(restored));
427
- });
456
+ }, shaper.sseActivity ? (evt) => {
457
+ // Activity is the agent describing its own work, so it can name a file it read — and
458
+ // it was handed PLACEHOLDERS, so what it echoes contains them. It is restored like any
459
+ // other text on the way back: a status line is not a side channel that skips the
460
+ // round trip and shows the user "[[PERSON_1]].md".
461
+ //
462
+ // restoreDeep, NOT the streaming restorer above: that one holds a partial token across
463
+ // chunks, and pushing an unrelated object through it would splice activity text into
464
+ // the middle of the assistant's message.
465
+ try {
466
+ res.write(shaper.sseActivity(restoreDeep(evt, vault)));
467
+ } catch { /* a client that hung up mid-turn is not a reason to fail the turn */ }
468
+ } : null);
428
469
  const tail = restorer.flush();
429
470
  if (tail) res.write(shaper.sseDelta(tail));
430
471
  res.write(shaper.sseTail());
@@ -481,7 +522,7 @@ function resample(input, from, to) {
481
522
  return out;
482
523
  }
483
524
 
484
- const LOCAL_NAMESPACES = ['/tts', '/stt', '/ner', '/diarize', '/skills', '/config', '/logs', '/status', '/admin'];
525
+ const LOCAL_NAMESPACES = ['/tts', '/stt', '/ner', '/diarize', '/skills', '/redact', '/config', '/logs', '/status', '/admin'];
485
526
 
486
527
  async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
487
528
  let upstream;
@@ -1487,6 +1528,30 @@ export function createGateway(cfg = loadConfig()) {
1487
1528
  return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
1488
1529
  }
1489
1530
 
1531
+ // THE SKILLS ON THIS MACHINE, asked of the gateway rather than the bridge.
1532
+ //
1533
+ // The bridge is what reads the user's disk, so this proxies it — but a client should
1534
+ // have ONE address for everything. A client that talks to the gateway for models and the
1535
+ // bridge for skills has to know both are up, hold both tokens, and handle two failure
1536
+ // modes for one screen; and the direct-to-bridge path is the one with no policy in front
1537
+ // of it, so making it necessary for a feature is how it becomes the habit.
1538
+ if (req.method === 'GET' && pathname === '/skills') {
1539
+ const base = String(cfg.bridge?.url || '').replace(/\/$/, '');
1540
+ if (!base) return sendJson(res, 503, { error: { message: 'no bridge is configured', type: 'no_bridge' } });
1541
+ const token = readBridgeToken(cfg.bridge?.token);
1542
+ try {
1543
+ const r = await fetch(`${base}/skills`, {
1544
+ headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
1545
+ signal: AbortSignal.timeout(8000),
1546
+ });
1547
+ const data = await r.json().catch(() => ({}));
1548
+ if (!r.ok) return sendJson(res, r.status, { error: { message: data?.error || `bridge ${r.status}`, type: 'bridge_error' } });
1549
+ return sendJson(res, 200, { skills: Array.isArray(data?.skills) ? data.skills : [] });
1550
+ } catch (e) {
1551
+ return sendJson(res, 502, { error: { message: `bridge unreachable: ${e.message}`, type: 'bridge_unreachable' } });
1552
+ }
1553
+ }
1554
+
1490
1555
  // Model discovery — aggregate every destination's models.
1491
1556
  if (req.method === 'GET' && /\/models$/.test(pathname)) {
1492
1557
  return sendJson(res, 200, await aggregateModelsAsync(cfg));
package/src/shape.js CHANGED
@@ -38,6 +38,25 @@ export function openaiChat(model) {
38
38
  sseTail() {
39
39
  return sse({ ...base, choices: [{ index: 0, delta: {}, finish_reason: 'stop' }] }) + 'data: [DONE]\n\n';
40
40
  },
41
+ /**
42
+ * What the agent is DOING, on a chunk an OpenAI client ignores.
43
+ *
44
+ * The chunk is well-formed and carries an empty delta, so a strict client sees a frame
45
+ * with nothing in it and moves on — which is what makes this additive rather than a
46
+ * change to the wire contract. A ChatPanel client reads the extra `chatpanel` key.
47
+ *
48
+ * This exists because an agent spends its first ten seconds reading files, and a client
49
+ * routed through the gateway had no way to know that while one talking to the bridge
50
+ * directly did. A protocol gap that rewards going around the redacting proxy is a
51
+ * security problem wearing a UI problem's clothes.
52
+ */
53
+ sseActivity(evt) {
54
+ return sse({
55
+ ...base,
56
+ choices: [{ index: 0, delta: {}, finish_reason: null }],
57
+ chatpanel: { kind: 'activity', event: evt },
58
+ });
59
+ },
41
60
  // Tool-relay (agent destinations): emit the agent's tool call as an OpenAI
42
61
  // tool_calls delta, then end the turn with finish_reason:tool_calls.
43
62
  sseToolCalls(calls) {