@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 +1 -1
- package/src/bridge.js +17 -3
- package/src/router.js +146 -4
- package/src/server.js +71 -6
- package/src/shape.js +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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) => {
|
|
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)) {
|
|
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.
|
|
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
|
|
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
|
-
|
|
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) {
|