@chatpanel/gateway 0.1.9 → 0.2.0

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.1.9",
3
+ "version": "0.2.0",
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
@@ -45,6 +45,28 @@ export function toBridgeMessages(messages) {
45
45
  .map((m) => ({ role: m.role, content: flattenContent(m.content) }));
46
46
  }
47
47
 
48
+ // Open a bridge /chat stream WITH tool specs (pageTools), returning the raw fetch
49
+ // Response so the tool-relay can hold the reader open across the OpenAI round-trip.
50
+ export async function openBridgeChat({ bridgeUrl, agent, token, messages, system, specs, options, signal }) {
51
+ const res = await fetch(`${bridgeUrl.replace(/\/$/, '')}/chat`, {
52
+ method: 'POST',
53
+ headers: { 'content-type': 'application/json', ...(token ? { authorization: `Bearer ${token}` } : {}) },
54
+ body: JSON.stringify({
55
+ agent,
56
+ messages: toBridgeMessages(messages),
57
+ system: system || '',
58
+ options: options || {},
59
+ ...(Array.isArray(specs) && specs.length ? { pageTools: { specs } } : {}),
60
+ }),
61
+ signal,
62
+ });
63
+ if (!res.ok || !res.body) {
64
+ const detail = await res.text().catch(() => '');
65
+ throw new Error(`bridge /chat HTTP ${res.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`);
66
+ }
67
+ return res;
68
+ }
69
+
48
70
  // Stream a turn through the bridge. Calls onText(restorableChunk) for each delta
49
71
  // of model text and returns the full (un-restored) text. Throws on bridge error.
50
72
  export async function streamBridgeChat({ bridgeUrl, agent, token, messages, system, options, signal }, onText) {
package/src/config.js CHANGED
@@ -20,6 +20,12 @@ const DEFAULTS = {
20
20
  // BYO keys, OpenRouter, …). Client passes its own auth through.
21
21
  backend: 'bridge',
22
22
 
23
+ // Model router: the gateway routes each request to a destination by the model
24
+ // name the client asks for. Empty = derive destinations from backend/bridge/
25
+ // upstreams below (back-compat). Each entry:
26
+ // { id, type:'agent'|'api', agent?, baseUrl?, protocol?, models:[..] }
27
+ destinations: [],
28
+
23
29
  // Security: legitimate clients (opencode/pi/codex/SDKs) are local processes that
24
30
  // send NO Origin header. A browser always attaches one. So we REJECT any request
25
31
  // bearing an Origin not in this allowlist — this stops a malicious web page from
@@ -29,6 +29,7 @@ export function persistConfig(cfg, path = configPath()) {
29
29
  export function publicConfig(cfg, { proUnlocked = false } = {}) {
30
30
  return {
31
31
  backend: cfg.backend,
32
+ destinations: Array.isArray(cfg.destinations) ? cfg.destinations : [],
32
33
  bridge: { url: cfg.bridge?.url, agent: cfg.bridge?.agent, hasToken: !!cfg.bridge?.token },
33
34
  upstreams: cfg.upstreams,
34
35
  redaction: {
@@ -48,6 +49,16 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
48
49
  // Merge an editable patch into the live cfg. Only known fields; ignores the rest.
49
50
  export function applyConfigPatch(cfg, patch = {}) {
50
51
  if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
52
+ if (Array.isArray(patch.destinations)) {
53
+ cfg.destinations = patch.destinations
54
+ .filter((d) => d && typeof d.id === 'string' && (d.type === 'agent' || d.type === 'api'))
55
+ .map((d) => ({
56
+ id: d.id, type: d.type,
57
+ ...(d.type === 'agent' ? { agent: d.agent || d.id } : {}),
58
+ ...(d.type === 'api' ? { baseUrl: String(d.baseUrl || ''), protocol: d.protocol === 'anthropic' ? 'anthropic' : 'openai' } : {}),
59
+ models: Array.isArray(d.models) ? d.models.filter((m) => typeof m === 'string' && m) : [],
60
+ }));
61
+ }
51
62
  if (patch.bridge && typeof patch.bridge === 'object') {
52
63
  if (typeof patch.bridge.url === 'string') cfg.bridge.url = patch.bridge.url;
53
64
  if (typeof patch.bridge.agent === 'string') cfg.bridge.agent = patch.bridge.agent;
package/src/router.js ADDED
@@ -0,0 +1,57 @@
1
+ // Model router: the gateway exposes one localhost endpoint, and routes each
2
+ // request to a DESTINATION by the model name the client asks for. A destination
3
+ // is either a CLI agent (driven via the bridge, subscription login) or an API
4
+ // (forwarded to a provider/local server, client brings the key).
5
+ //
6
+ // destination = {
7
+ // id, unique name (also a valid model alias)
8
+ // type: 'agent' | 'api',
9
+ // agent, (agent) bridge agent id: codex/claude/opencode/pi
10
+ // baseUrl, protocol, (api) where to forward + 'openai'|'anthropic'
11
+ // models: [..], models this destination serves (for /v1/models)
12
+ // }
13
+ //
14
+ // /v1/models aggregates every destination's models so clients can discover them.
15
+
16
+ // Back-compat: if no destinations are configured, synthesize them from the legacy
17
+ // backend/upstreams config so existing installs keep working unchanged.
18
+ export function listDestinations(cfg) {
19
+ if (Array.isArray(cfg.destinations) && cfg.destinations.length) return cfg.destinations;
20
+ if (cfg.backend === 'api') {
21
+ return [
22
+ { id: 'openai', type: 'api', protocol: 'openai', baseUrl: cfg.upstreams?.openai?.baseUrl, models: [] },
23
+ { id: 'anthropic', type: 'api', protocol: 'anthropic', baseUrl: cfg.upstreams?.anthropic?.baseUrl, models: [] },
24
+ ];
25
+ }
26
+ const agents = [...new Set([cfg.bridge?.agent || 'codex', 'codex', 'claude', 'opencode', 'pi'])];
27
+ return agents.map((a) => ({ id: a, type: 'agent', agent: a, models: [a] }));
28
+ }
29
+
30
+ // Pick the destination that serves `model` (explicit membership → id/agent match →
31
+ // a same-protocol fallback → the first destination).
32
+ export function resolveDestination(model, cfg, kind) {
33
+ const dests = listDestinations(cfg);
34
+ const wantsAnthropic = kind === 'anthropic';
35
+ return (
36
+ (model && dests.find((d) => Array.isArray(d.models) && d.models.includes(model)))
37
+ || (model && dests.find((d) => d.id === model || d.agent === model))
38
+ || dests.find((d) => d.type === 'agent' || (wantsAnthropic ? d.protocol === 'anthropic' : d.protocol !== 'anthropic'))
39
+ || dests[0]
40
+ || null
41
+ );
42
+ }
43
+
44
+ // Aggregate every destination's models for GET /v1/models.
45
+ export function aggregateModels(cfg) {
46
+ const data = [];
47
+ const seen = new Set();
48
+ for (const d of listDestinations(cfg)) {
49
+ const models = (Array.isArray(d.models) && d.models.length) ? d.models : [d.id];
50
+ for (const m of models) {
51
+ if (!m || seen.has(m)) continue;
52
+ seen.add(m);
53
+ data.push({ id: m, object: 'model', owned_by: d.type === 'agent' ? 'chatpanel-bridge' : (d.id || 'api') });
54
+ }
55
+ }
56
+ return { object: 'list', data };
57
+ }
package/src/server.js CHANGED
@@ -27,11 +27,12 @@ import { shaperFor } from './shape.js';
27
27
  import { startNer } from './ner.js';
28
28
  import { resolvePro, meter, usage } from './freegate.js';
29
29
  import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
30
+ import { resolveDestination, aggregateModels } from './router.js';
30
31
  import * as openai from './openai.js';
31
32
  import * as responses from './responses.js';
32
33
  import * as anthropic from './anthropic.js';
33
34
 
34
- export const VERSION = '0.1.9';
35
+ export const VERSION = '0.2.0';
35
36
 
36
37
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
37
38
 
@@ -110,17 +111,13 @@ function forwardHeaders(headers, base) {
110
111
 
111
112
  // ---- backend: bridge -------------------------------------------------------
112
113
 
113
- async function handleBridge(req, res, { kind, adapter, redactable, pathname }, body, vault, cfg) {
114
+ async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride }, body, vault, cfg) {
114
115
  if (!redactable) {
115
- if (/\/models$/.test(pathname)) {
116
- const agents = [...new Set([cfg.bridge.agent, 'codex', 'claude', 'opencode', 'pi'])];
117
- return sendJson(res, 200, { object: 'list', data: agents.map((id) => ({ id, object: 'model', owned_by: 'chatpanel-bridge' })) });
118
- }
119
116
  return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
120
117
  }
121
118
 
122
119
  const { messages, system } = adapter.toTurn(body);
123
- const agent = pickAgent(body?.model, cfg);
120
+ const agent = agentOverride || pickAgent(body?.model, cfg);
124
121
  const wantStream = body?.stream === true;
125
122
  const shaper = shaperFor(kind, body?.model || agent);
126
123
  const token = readBridgeToken(cfg.bridge.token);
@@ -235,6 +232,11 @@ export function createGateway(cfg = loadConfig()) {
235
232
  return sendJson(res, 200, publicConfig(cfg, { proUnlocked }));
236
233
  }
237
234
 
235
+ // Model discovery — aggregate every destination's models.
236
+ if (req.method === 'GET' && /\/models$/.test(pathname)) {
237
+ return sendJson(res, 200, aggregateModels(cfg));
238
+ }
239
+
238
240
  const r = route(pathname, req.headers, cfg);
239
241
  let raw;
240
242
  try {
@@ -267,17 +269,20 @@ export function createGateway(cfg = loadConfig()) {
267
269
  const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
268
270
  vault = v;
269
271
  outBody = Buffer.from(JSON.stringify(body), 'utf8');
270
- if (cfg.logRequests) console.log(`[gateway] ${req.method} ${pathname} · redacted ${count}/${segs.length} segment(s) · ${cfg.backend}`);
271
272
  }
272
- } else if (cfg.logRequests) {
273
- console.log(`[gateway] ${req.method} ${pathname} · ${cfg.backend}`);
274
273
  }
275
274
 
276
- if (cfg.backend === 'bridge') {
277
- return handleBridge(req, res, { ...r, pathname }, body, vault, cfg);
275
+ // Route by the requested model → a destination (agent via the bridge, or an
276
+ // API we forward to). Falls back to the legacy backend when none configured.
277
+ const dest = resolveDestination(body?.model, cfg, r.kind);
278
+ if (cfg.logRequests) {
279
+ console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'}`);
280
+ }
281
+ if (dest && dest.type === 'api') {
282
+ if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
283
+ return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl }, outBody, vault);
278
284
  }
279
- if (!r.base) return sendJson(res, 502, { error: 'no upstream configured' });
280
- return handleApi(req, res, { ...r, pathname, search: url.search }, outBody, vault);
285
+ return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent }, body, vault, cfg);
281
286
  });
282
287
  }
283
288
 
@@ -0,0 +1,99 @@
1
+ // Tool-call relay for the bridge backend.
2
+ //
3
+ // The bridge relays a CLI agent's tool calls MID-SESSION: it emits a
4
+ // `tool_request` over the /chat SSE and pauses the agent until a POST /tool-result
5
+ // arrives. OpenAI function-calling is the opposite — the model returns `tool_calls`,
6
+ // the response ENDS, and the client re-requests with results. To bridge the two we
7
+ // keep the codex /chat stream OPEN across the OpenAI round-trip:
8
+ //
9
+ // 1. open bridge /chat with the client's tools as pageTools.specs
10
+ // 2. read SSE; on `tool_request` → emit OpenAI tool_calls, finish_reason:tool_calls,
11
+ // and PARK the session (reader stays open; codex is paused on the bridge side)
12
+ // 3. on the client's follow-up request (carrying the tool result + tool_call_id) →
13
+ // POST /tool-result to the bridge and RESUME reading the same stream
14
+ //
15
+ // Tool args are restored on the way out (so the client runs on real values) and
16
+ // the result is redacted on the way back (so codex only sees placeholders).
17
+
18
+ import { randomUUID } from 'node:crypto';
19
+ import { restoreDeep, redactText, createVault } from '@chatpanel/pii';
20
+
21
+ const sessions = new Map(); // gwSessionId -> { reader, decoder, buf, bridgeSessionId, toolId, vault, redactOpts, bridgeUrl, token }
22
+
23
+ // Encode/decode the gateway session into the OpenAI tool_call id so the client
24
+ // echoes it back on the follow-up request, letting us find the parked session.
25
+ const encodeToolCallId = (gwId, toolId) => `gwtr_${gwId}_${toolId}`;
26
+ export function parseToolCallId(id) {
27
+ const m = /^gwtr_([^_]+)_(.+)$/.exec(String(id || ''));
28
+ return m ? { gwId: m[1], toolId: m[2] } : null;
29
+ }
30
+
31
+ // Map OpenAI `tools` → the bridge's pageTools.specs ({ name, description, parameters }).
32
+ export function toolsToSpecs(tools) {
33
+ return (Array.isArray(tools) ? tools : [])
34
+ .filter((t) => t && t.type === 'function' && t.function?.name)
35
+ .map((t) => ({ name: t.function.name, description: t.function.description || '', parameters: t.function.parameters || { type: 'object', properties: {} } }));
36
+ }
37
+
38
+ export function createRelaySession({ vault, redactOpts, bridgeUrl, token }) {
39
+ const id = randomUUID().slice(0, 8);
40
+ const s = { id, reader: null, decoder: new TextDecoder(), buf: '', bridgeSessionId: null, toolId: null, vault: vault || createVault(), redactOpts: redactOpts || { tier: 'basic' }, bridgeUrl, token, done: false };
41
+ sessions.set(id, s);
42
+ return s;
43
+ }
44
+ export const getRelaySession = (id) => sessions.get(id);
45
+ export function endRelaySession(id) {
46
+ const s = sessions.get(id);
47
+ if (s?.reader) { try { s.reader.cancel(); } catch { /* ignore */ } }
48
+ sessions.delete(id);
49
+ }
50
+
51
+ // Drain one SSE event block ("data: {json}\n\n") at a time from the held reader,
52
+ // dispatching to handlers until a tool_request parks us or the stream is done.
53
+ // handlers: { onText(restorableText), onToolRequest({name, restoredArgs, toolId, bridgeSessionId}), onDone(), onError(err) }
54
+ // Returns 'parked' (hit a tool_request) or 'done'.
55
+ export async function pumpBridgeStream(s, handlers) {
56
+ for (;;) {
57
+ let nl;
58
+ while ((nl = s.buf.indexOf('\n\n')) !== -1) {
59
+ const block = s.buf.slice(0, nl);
60
+ s.buf = s.buf.slice(nl + 2);
61
+ for (const line of block.split('\n')) {
62
+ const t = line.trim();
63
+ if (!t.startsWith('data:')) continue;
64
+ const payload = t.slice(5).trim();
65
+ if (!payload || payload === '[DONE]') continue;
66
+ let evt; try { evt = JSON.parse(payload); } catch { continue; }
67
+ if (evt.type === 'delta' && typeof evt.text === 'string') {
68
+ handlers.onText(evt.text);
69
+ } else if (evt.type === 'tool_request') {
70
+ s.bridgeSessionId = evt.session; s.toolId = evt.id;
71
+ // restore placeholders so the CLIENT runs the tool on REAL values
72
+ const restoredArgs = restoreDeep(evt.input ?? {}, s.vault);
73
+ handlers.onToolRequest({ name: evt.name, restoredArgs, toolId: encodeToolCallId(s.id, evt.id) });
74
+ return 'parked';
75
+ } else if (evt.type === 'done') {
76
+ s.done = true; handlers.onDone?.(); return 'done';
77
+ } else if (evt.type === 'error') {
78
+ handlers.onError?.(new Error(evt.error || 'bridge error')); return 'done';
79
+ }
80
+ // status / reasoning / tool (summary) events are ignored
81
+ }
82
+ }
83
+ const { done, value } = await s.reader.read();
84
+ if (done) { s.done = true; handlers.onDone?.(); return 'done'; }
85
+ s.buf += s.decoder.decode(value, { stream: true });
86
+ }
87
+ }
88
+
89
+ // Deliver a client's tool result back to the parked codex session: redact it (so
90
+ // codex sees placeholders) and POST /tool-result; the bridge then resumes the run.
91
+ export async function deliverToolResult(s, content) {
92
+ const redacted = redactText(typeof content === 'string' ? content : JSON.stringify(content), s.vault, s.redactOpts);
93
+ const res = await fetch(`${s.bridgeUrl.replace(/\/$/, '')}/tool-result`, {
94
+ method: 'POST',
95
+ headers: { 'content-type': 'application/json', ...(s.token ? { authorization: `Bearer ${s.token}` } : {}) },
96
+ body: JSON.stringify({ session: s.bridgeSessionId, id: s.toolId, result: redacted }),
97
+ });
98
+ if (!res.ok) throw new Error(`bridge /tool-result HTTP ${res.status}`);
99
+ }