@chatpanel/bridge 0.11.4 → 0.11.6

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/bridge",
3
- "version": "0.11.4",
3
+ "version": "0.11.6",
4
4
  "type": "module",
5
5
  "description": "Local bridge that exposes the AI coding agents installed on your machine \u2014 Claude Code (CLI), Codex (CLI), and Antigravity CLI (formerly Gemini CLI, which remains available for business/enterprise) \u2014 to the ChatPanel Chrome extension over a localhost SSE endpoint. Bring your own agent.",
6
6
  "keywords": [
@@ -24,7 +24,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
24
24
  // The service and everything under it. `config.js` is deliberately absent: it resolves paths
25
25
  // and reads the bridge token from disk, and the bridge already knows both.
26
26
  const FILES = [
27
- 'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js',
27
+ 'normalize.js', 'pairing.js', 'invoke.js', 'stream.js', 'bridge.js', 'gateway.js',
28
28
  'eventlog.js', 'service.js', 'adapters/telegram.js',
29
29
  ];
30
30
 
@@ -46,6 +46,13 @@ export function startTelegram({
46
46
  savePairing = async () => {},
47
47
  appender, // createEventLog(...) or nullEventLog()
48
48
  agent = 'claude',
49
+ // Which transport answers, and with what. `bridge` runs a CLI agent on this machine;
50
+ // `gateway` reaches any destination the user configured there — an API provider or, via the
51
+ // gateway's own bridge backend, the same CLI agents. The adapter must not be able to tell
52
+ // which it has: both expose chat()/cancel() and fold into one reply state.
53
+ transport = bridge,
54
+ model = '',
55
+ provider = '',
49
56
  system = '',
50
57
  redact = { tier: 'basic' },
51
58
  privacy = 'standard',
@@ -113,7 +120,7 @@ export function startTelegram({
113
120
  }
114
121
  if (name === 'stop') {
115
122
  const st = chatState(norm.chatId);
116
- const ok = await bridge.cancel(st.runId, { baseUrl, token });
123
+ const ok = await transport.cancel(st.runId, { baseUrl, token });
117
124
  st.runId = null;
118
125
  return void send(norm.chatId, ok ? '⏹ stopped.' : 'nothing running.');
119
126
  }
@@ -151,15 +158,16 @@ export function startTelegram({
151
158
  const messages = [...st.history, { role: 'user', content: redacted }];
152
159
 
153
160
  try {
154
- const finalState = await bridge.chat(
155
- { agent, system, messages, images, options: { reach } },
161
+ const finalState = await transport.chat(
162
+ { agent, model, provider, system, messages, images, options: { reach } },
156
163
  {
157
164
  baseUrl, token, signal,
158
165
  onEvent: (ev, state) => {
159
- if (ev.type === 'run') st.runId = state.runId;
160
- // Throttled live edit: restore the user's own values + egress-scrub fresh secrets, so
161
- // the user watches real text stream in but the provider never carries a leaked secret.
162
- if ((ev.type === 'delta' || ev.type === 'done') && replyId && (ev.type === 'done' || gate.ready())) {
166
+ if (state.runId) st.runId = state.runId;
167
+ // Driven by the folded STATE, not by an event's `type`: the bridge emits
168
+ // {type:'delta'} and the gateway emits OpenAI chunks, and this has to work on both.
169
+ // The `first !== shown` guard below makes a no-text event a no-op anyway.
170
+ if (replyId && (state.done || gate.ready())) {
163
171
  const text = outboundText(state.text, st.vault, { privacy });
164
172
  const first = splitForTelegram(text || '…')[0];
165
173
  if (first && first !== shown) { shown = first; edit(norm.chatId, replyId, first).catch(() => {}); }
@@ -0,0 +1,114 @@
1
+ // GENERATED — do not edit.
2
+ // Source of truth: chatpanel-channels/src/gateway.js (npm @chatpanel/channels).
3
+ // Edit there, then run: npm run sync:channels
4
+ //
5
+ // Vendored rather than depended on: the bridge ships zero runtime dependencies so a
6
+ // curl one-liner install cannot fail on someone's registry, and so the compiled
7
+ // single-file binary has nothing to resolve. Package imports are rewritten to the
8
+ // vendored engines (src/pii, src/events) by the sync script.
9
+
10
+ // Gateway backend: reach ANY configured destination — an API provider or a CLI agent —
11
+ // through the ChatPanel gateway's OpenAI-compatible endpoint.
12
+ //
13
+ // channel → POST http://127.0.0.1:4320/v1/chat/completions { model, messages, stream }
14
+ //
15
+ // WHY THIS EXISTS ALONGSIDE bridge.js. The bridge runs CLI agents, and that is all it can
16
+ // answer with — so a phone could only ever talk to Claude Code or Codex, never to the OpenAI,
17
+ // Anthropic or local-model endpoints the same user already configured. Those live in the
18
+ // gateway, which persists its `destinations` (0600, keys included) and routes a model id to
19
+ // an API provider OR back to the bridge for an agent. So the gateway is the superset, and
20
+ // pointing a channel at it is what makes "answer from my phone" work with every target the
21
+ // user has rather than a subset.
22
+ //
23
+ // NO NEW SECRET. The alternative was teaching the bridge to hold provider API keys, which
24
+ // would have put them on disk a second time, in a second format, with a second thing to
25
+ // rotate. The gateway already holds them and already guards them; this borrows the routing
26
+ // instead of copying the credentials.
27
+ //
28
+ // The /v1 data plane is deliberately unauthenticated for local clients — that is the
29
+ // gateway's product — so there is no token to present here, and none to leak.
30
+
31
+ import { parseSse, foldOpenAiEvent, initialState } from './stream.js';
32
+
33
+ // Per-turn aborts, so /stop can cancel a gateway turn the way it cancels a bridge run. The
34
+ // bridge hands out a run id for this; OpenAI's API has no such handle, so we mint one and
35
+ // keep the controller behind it rather than leaving /stop silently broken on this transport.
36
+ const inflight = new Map();
37
+
38
+ /**
39
+ * Drive one turn against a gateway destination. Same shape as bridge.chat so the adapter does
40
+ * not know which transport it has: streams events to onEvent(ev, state) and returns the folded
41
+ * final state.
42
+ */
43
+ export async function chat({ model = '', provider = '', system = '', messages = [], options = {} }, {
44
+ baseUrl, signal, onEvent = () => {},
45
+ } = {}) {
46
+ const runId = `gw_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
47
+ const ac = new AbortController();
48
+ inflight.set(runId, ac);
49
+ // The caller's signal (the poll loop shutting down) must still abort the turn.
50
+ const relay = () => ac.abort();
51
+ signal?.addEventListener?.('abort', relay, { once: true });
52
+
53
+ let state = initialState();
54
+ const emit = (ev) => { state = foldOpenAiEvent(state, ev); onEvent(ev, state); };
55
+ emit({ type: 'run', id: runId });
56
+
57
+ try {
58
+ const res = await fetch(`${String(baseUrl).replace(/\/$/, '')}/v1/chat/completions`, {
59
+ method: 'POST',
60
+ headers: {
61
+ 'content-type': 'application/json',
62
+ // ChatPanel's routing metadata goes in HEADERS, never in the request body. It rode the
63
+ // body first and NVIDIA answered "unsupported parameters": OpenAI-compatible providers
64
+ // validate the body strictly and reject unknown fields, while ignoring unknown headers.
65
+ // The gateway strips x-chatpanel-* before forwarding, so the provider never sees it.
66
+ ...(options?.reach ? { 'x-chatpanel-reach': options.reach } : {}),
67
+ // WHICH provider, not just which model. A model id is not a unique key — two providers
68
+ // can serve the same one — so without this the gateway picks whichever destination it
69
+ // lists first and the call goes out on a key the user never chose.
70
+ ...(provider ? { 'x-chatpanel-destination': provider } : {}),
71
+ },
72
+ body: JSON.stringify({
73
+ model,
74
+ stream: true,
75
+ messages: [
76
+ ...(system ? [{ role: 'system', content: system }] : []),
77
+ ...messages.map((m) => ({ role: m.role, content: String(m.content ?? '') })),
78
+ ],
79
+ }),
80
+ signal: ac.signal,
81
+ });
82
+ if (!res.ok || !res.body) {
83
+ const detail = await res.text().catch(() => '');
84
+ throw new Error(`gateway ${res.status}${detail ? `: ${detail.slice(0, 300)}` : ''}`);
85
+ }
86
+ let buffer = '';
87
+ const decoder = new TextDecoder();
88
+ for await (const chunk of res.body) {
89
+ buffer += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
90
+ const { events, rest } = parseSse(buffer);
91
+ buffer = rest;
92
+ for (const ev of events) emit(ev);
93
+ }
94
+ // OpenAI streams end with `data: [DONE]`, which is not JSON and is dropped by parseSse —
95
+ // so a stream that ended cleanly still has to be marked done here.
96
+ if (!state.done) state = { ...state, done: true };
97
+ return state;
98
+ } catch (e) {
99
+ if (ac.signal.aborted) return { ...state, done: true };
100
+ return { ...state, done: true, error: e?.message || String(e) };
101
+ } finally {
102
+ inflight.delete(runId);
103
+ signal?.removeEventListener?.('abort', relay);
104
+ }
105
+ }
106
+
107
+ /** Stop a run by the id emitted as the first {type:'run'} event. Best-effort, like the bridge's. */
108
+ export async function cancel(runId) {
109
+ const ac = runId && inflight.get(runId);
110
+ if (!ac) return false;
111
+ ac.abort();
112
+ inflight.delete(runId);
113
+ return true;
114
+ }
@@ -27,11 +27,23 @@
27
27
  import path from 'node:path';
28
28
  import { readFile, writeFile, mkdir, rm, stat } from 'node:fs/promises';
29
29
  import { createPairingStore } from './pairing.js';
30
+ import * as bridgeTransport from './bridge.js';
31
+ import * as gateway from './gateway.js';
32
+
33
+ // The gateway's fixed local port (see chatpanel-gateway: 4319 bridge / 4320 gateway).
34
+ const DEFAULT_GATEWAY_URL = 'http://127.0.0.1:4320';
30
35
  import { createEventLog } from './eventlog.js';
31
36
  import { startTelegram } from './adapters/telegram.js';
32
37
 
33
38
  const TELEGRAM_API = 'https://api.telegram.org';
34
- export const DEFAULT_SETTINGS = Object.freeze({ agent: 'claude', privacy: 'standard', tier: 'basic' });
39
+ // `agent` routes through the bridge (a CLI on this machine). `model` routes through the
40
+ // gateway, which reaches every destination the user configured there — API providers AND, via
41
+ // its own bridge backend, the same agents. They are mutually exclusive: update() clears one
42
+ // when the other is set, because "which thing answers" is one choice, not two.
43
+ export const DEFAULT_SETTINGS = Object.freeze({
44
+ agent: 'claude', model: '', provider: '', gatewayUrl: DEFAULT_GATEWAY_URL,
45
+ privacy: 'standard', tier: 'basic',
46
+ });
35
47
 
36
48
  // Restart backoff. A long-poll that dies (network drop, laptop asleep, Telegram hiccup) must
37
49
  // come back on its own — a channel nobody is watching is exactly the one that must self-heal —
@@ -120,9 +132,14 @@ export function createChannelService({
120
132
  function spawnLoop(botToken) {
121
133
  controller = new AbortController();
122
134
  running = true;
135
+ // One choice, resolved here so the adapter never has to ask "which kind am I?".
136
+ const viaGateway = !!settings.model;
123
137
  const done = startAdapter({
124
138
  botToken,
125
- baseUrl: bridge.baseUrl,
139
+ transport: viaGateway ? gateway : bridgeTransport,
140
+ model: viaGateway ? settings.model : '',
141
+ provider: viaGateway ? (settings.provider || '') : '',
142
+ baseUrl: viaGateway ? (settings.gatewayUrl || DEFAULT_GATEWAY_URL) : bridge.baseUrl,
126
143
  token: bridge.token,
127
144
  pairing,
128
145
  savePairing,
@@ -232,7 +249,13 @@ export function createChannelService({
232
249
  async update(patch = {}) {
233
250
  await load();
234
251
  const next = { ...settings };
235
- for (const k of ['agent', 'privacy', 'tier', 'system']) if (patch[k] != null) next[k] = patch[k];
252
+ for (const k of ['agent', 'model', 'provider', 'gatewayUrl', 'privacy', 'tier', 'system']) {
253
+ if (patch[k] != null) next[k] = patch[k];
254
+ }
255
+ // Picking one target unpicks the other. Without this a stale `model` would silently win
256
+ // over the agent the user just chose, and the screen would disagree with the machine.
257
+ if (patch.agent != null && patch.model == null) { next.model = ''; next.provider = ''; }
258
+ if (patch.model) next.agent = '';
236
259
  settings = next;
237
260
  await saveSettings();
238
261
  // Settings are read when the loop starts, so a change only lands on a restart.
@@ -273,7 +296,17 @@ export function createChannelService({
273
296
  bot: bot ? { ...bot } : null,
274
297
  error: lastError,
275
298
  paired: pairing.list(),
276
- settings: { agent: settings.agent, privacy: settings.privacy, tier: settings.tier },
299
+ settings: {
300
+ agent: settings.agent,
301
+ model: settings.model || '',
302
+ provider: settings.provider || '',
303
+ gatewayUrl: settings.gatewayUrl || DEFAULT_GATEWAY_URL,
304
+ privacy: settings.privacy,
305
+ tier: settings.tier,
306
+ },
307
+ // Which transport a message will actually take, so a screen can say so rather than
308
+ // inferring it from two fields and getting it wrong.
309
+ via: settings.model ? 'gateway' : 'bridge',
277
310
  };
278
311
  },
279
312
  };
@@ -35,6 +35,26 @@ export function foldEvent(state, ev) {
35
35
  }
36
36
  }
37
37
 
38
+ /**
39
+ * Fold one OpenAI-style chunk (what the gateway streams) into the same reply state, so a
40
+ * caller cannot tell which transport it has. Shares parseSse: `data: [DONE]` is not JSON and
41
+ * is dropped there, which is why the transport marks `done` itself when the stream ends.
42
+ */
43
+ export function foldOpenAiEvent(state, ev) {
44
+ if (ev?.type === 'run') return { ...state, runId: ev.id || state.runId };
45
+ // An error can arrive as a streamed frame rather than an HTTP status.
46
+ if (ev?.error) return { ...state, done: true, error: ev.error.message || String(ev.error) };
47
+ const choice = ev?.choices?.[0];
48
+ let next = state;
49
+ const delta = choice?.delta?.content;
50
+ if (typeof delta === 'string' && delta) next = { ...next, text: next.text + delta };
51
+ // Non-streaming replies (a gateway destination that cannot stream) carry the whole message.
52
+ const whole = choice?.message?.content;
53
+ if (!next.text && typeof whole === 'string' && whole) next = { ...next, text: whole };
54
+ if (choice?.finish_reason) next = { ...next, done: true };
55
+ return next;
56
+ }
57
+
38
58
  /**
39
59
  * Pull complete SSE events out of a growing buffer. Returns the parsed events and the
40
60
  * UNCONSUMED tail (a partial frame still arriving), which the caller prepends next read.
package/src/server.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  // Hardcoded (not read from package.json) so it survives Bun's single-file
68
68
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
69
69
  // this drifts from package.json, so the two can't silently diverge.
70
- const VERSION = '0.11.4';
70
+ const VERSION = '0.11.6';
71
71
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
72
72
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
73
73