@chatpanel/gateway 0.1.8 → 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 +1 -1
- package/src/bridge.js +22 -0
- package/src/config.js +6 -0
- package/src/configstore.js +21 -0
- package/src/ner.js +2 -2
- package/src/router.js +57 -0
- package/src/server.js +19 -14
- package/src/toolrelay.js +99 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.
|
|
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
|
package/src/configstore.js
CHANGED
|
@@ -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,10 +49,30 @@ 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;
|
|
54
65
|
}
|
|
66
|
+
// api backend: where redacted traffic is forwarded (the client still picks the
|
|
67
|
+
// model + sends its own key).
|
|
68
|
+
if (patch.upstreams && typeof patch.upstreams === 'object') {
|
|
69
|
+
for (const k of ['openai', 'anthropic']) {
|
|
70
|
+
const u = patch.upstreams[k];
|
|
71
|
+
if (u && typeof u.baseUrl === 'string' && u.baseUrl.trim()) {
|
|
72
|
+
cfg.upstreams[k] = { ...cfg.upstreams[k], baseUrl: u.baseUrl.trim() };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
55
76
|
if (patch.redaction && typeof patch.redaction === 'object') {
|
|
56
77
|
const r = patch.redaction;
|
|
57
78
|
if (r.tier === 'basic' || r.tier === 'full') cfg.redaction.tier = r.tier;
|
package/src/ner.js
CHANGED
|
@@ -124,12 +124,12 @@ export function startNer(cfg) {
|
|
|
124
124
|
});
|
|
125
125
|
|
|
126
126
|
// 3) Poll for readiness; wire detection when up.
|
|
127
|
-
const deadline = Date.now() +
|
|
127
|
+
const deadline = Date.now() + 300_000; // generous: first run installs deps
|
|
128
128
|
while (Date.now() < deadline && !stopped) {
|
|
129
129
|
if (await nerReachable(port, ac.signal)) { wire('ready'); return; }
|
|
130
130
|
await sleep(1000);
|
|
131
131
|
}
|
|
132
|
-
if (!stopped) console.log('[ner] not ready after
|
|
132
|
+
if (!stopped) console.log('[ner] not ready after 300s — continuing deterministic-only (run ./ner/run.sh manually to debug).');
|
|
133
133
|
})();
|
|
134
134
|
|
|
135
135
|
const stop = () => {
|
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.
|
|
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
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
package/src/toolrelay.js
ADDED
|
@@ -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
|
+
}
|