@chatpanel/gateway 0.6.64 → 0.6.66
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/router.js +92 -0
- package/src/server.js +32 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.66",
|
|
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/router.js
CHANGED
|
@@ -86,6 +86,32 @@ function apiShapeOf(d) {
|
|
|
86
86
|
return { api: 'openai', endpoints: ['/v1/chat/completions', '/v1/responses'] };
|
|
87
87
|
}
|
|
88
88
|
|
|
89
|
+
/**
|
|
90
|
+
* CAN THIS DESTINATION ANSWER AT ALL — before anyone sends a turn to it.
|
|
91
|
+
*
|
|
92
|
+
* This list is a ROUTING TABLE. It names every model the gateway would forward to, and a
|
|
93
|
+
* provider that lists four hundred can be configured with no key at all — so the list is
|
|
94
|
+
* mostly names that cannot answer. A user picks one, and finds out at 08:00 when a scheduled
|
|
95
|
+
* job comes back `Missing Authentication header` or `upstream fetch failed`, quoting undici
|
|
96
|
+
* about a destination they never knowingly chose.
|
|
97
|
+
*
|
|
98
|
+
* Local endpoints are the exception that must NOT be marked unconfigured: llama.cpp, Ollama
|
|
99
|
+
* and LM Studio take no key, and greying them out would hide the one setup that needs
|
|
100
|
+
* nothing. So this asks only the question it can answer honestly — "is a credential
|
|
101
|
+
* required here, and is one saved" — and says nothing about whether the server is up.
|
|
102
|
+
*
|
|
103
|
+
* Returns `''` when there is nothing to report.
|
|
104
|
+
*/
|
|
105
|
+
const LOCAL_HOST = /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\]|0\.0\.0\.0|[^/]*\.local)(:|\/|$)/i;
|
|
106
|
+
function unconfiguredReason(d) {
|
|
107
|
+
if (!d || d.type === 'agent') return '';
|
|
108
|
+
const base = String(d.baseUrl || '');
|
|
109
|
+
if (!base) return 'no endpoint URL is set for this provider';
|
|
110
|
+
if (LOCAL_HOST.test(base)) return ''; // a local server needs no key
|
|
111
|
+
if (d.apiKey || d.hasKey) return '';
|
|
112
|
+
return `no API key is saved for ${d.id}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
export function aggregateModels(cfg) {
|
|
90
116
|
const data = [];
|
|
91
117
|
const seen = new Set();
|
|
@@ -93,10 +119,15 @@ export function aggregateModels(cfg) {
|
|
|
93
119
|
if (!id || seen.has(id)) return;
|
|
94
120
|
seen.add(id);
|
|
95
121
|
const shape = apiShapeOf(d);
|
|
122
|
+
const blocked = unconfiguredReason(d);
|
|
96
123
|
data.push({
|
|
97
124
|
id,
|
|
98
125
|
object: 'model',
|
|
99
126
|
owned_by: owner,
|
|
127
|
+
// `configured` is about SETUP, not liveness: false means a turn sent here is known to
|
|
128
|
+
// fail for a reason the user can fix in Settings. Absent liveness is deliberate — the
|
|
129
|
+
// gateway does not probe every provider to draw a list.
|
|
130
|
+
...(blocked ? { configured: false, reason: blocked } : {}),
|
|
100
131
|
// Additive fields an OpenAI client ignores and a ChatPanel client uses to decide how
|
|
101
132
|
// to call, and to group a picker by provider instead of by a flat list of ids.
|
|
102
133
|
provider: d.id,
|
|
@@ -112,6 +143,44 @@ export function aggregateModels(cfg) {
|
|
|
112
143
|
return { object: 'list', data };
|
|
113
144
|
}
|
|
114
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The models each installed agent can be asked for.
|
|
148
|
+
*
|
|
149
|
+
* A CLI agent is not one model — Claude Code takes opus/sonnet/haiku, others enumerate their
|
|
150
|
+
* own — and listing only the agent id meant a user picked `claude` and got whatever default
|
|
151
|
+
* the CLI had. When that default is newer than the installed CLI, the answer is a version
|
|
152
|
+
* error about a model the user never chose.
|
|
153
|
+
*
|
|
154
|
+
* Asked of the bridge, which is the only thing that knows what each CLI supports, and only
|
|
155
|
+
* for agents that are actually INSTALLED: enumerating models for a CLI that is not there
|
|
156
|
+
* spends a subprocess per agent to describe something unusable.
|
|
157
|
+
*/
|
|
158
|
+
async function bridgeAgentModels(cfg, installed, timeoutMs) {
|
|
159
|
+
const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
|
|
160
|
+
if (!base || !installed) return new Map();
|
|
161
|
+
const token = readBridgeToken(cfg.bridge?.token);
|
|
162
|
+
const ids = [...installed.entries()].filter(([, ok]) => ok).map(([id]) => id);
|
|
163
|
+
const out = new Map();
|
|
164
|
+
await Promise.all(ids.map(async (id) => {
|
|
165
|
+
try {
|
|
166
|
+
const res = await fetch(`${base}/list-models`, {
|
|
167
|
+
method: 'POST',
|
|
168
|
+
headers: { 'content-type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
|
169
|
+
body: JSON.stringify({ agent: id }),
|
|
170
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
171
|
+
});
|
|
172
|
+
if (!res.ok) return;
|
|
173
|
+
const body = await res.json();
|
|
174
|
+
const models = (Array.isArray(body?.models) ? body.models : [])
|
|
175
|
+
.map((m) => (typeof m === 'string' ? m : m?.id || m?.name || ''))
|
|
176
|
+
.map((m) => String(m).trim())
|
|
177
|
+
.filter(Boolean);
|
|
178
|
+
if (models.length) out.set(id, models.slice(0, 40));
|
|
179
|
+
} catch { /* an agent that will not enumerate still works under its bare id */ }
|
|
180
|
+
}));
|
|
181
|
+
return out;
|
|
182
|
+
}
|
|
183
|
+
|
|
115
184
|
/** id → installed, from the bridge's own /health. `null` when it could not be asked. */
|
|
116
185
|
async function bridgeAgentAvailability(cfg, timeoutMs) {
|
|
117
186
|
const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
|
|
@@ -158,6 +227,27 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
|
158
227
|
}
|
|
159
228
|
}
|
|
160
229
|
|
|
230
|
+
// Each installed agent's own models, listed as `agent/model` beside the bare id. The bare
|
|
231
|
+
// id stays and still means "the agent's default", so nothing that already works breaks.
|
|
232
|
+
const agentModels = await bridgeAgentModels(cfg, agentAvailability, timeoutMs);
|
|
233
|
+
for (const [agent, models] of agentModels) {
|
|
234
|
+
const parent = base.data.find((m) => m.id === agent);
|
|
235
|
+
if (!parent) continue;
|
|
236
|
+
for (const model of models) {
|
|
237
|
+
const id = `${agent}/${model}`;
|
|
238
|
+
if (seen.has(id)) continue;
|
|
239
|
+
seen.add(id);
|
|
240
|
+
base.data.push({
|
|
241
|
+
...parent,
|
|
242
|
+
id,
|
|
243
|
+
// `model` is what the picker shows under the agent's heading; the agent stays the
|
|
244
|
+
// provider, so the grouping puts them together without any id parsing.
|
|
245
|
+
model,
|
|
246
|
+
available: true,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
161
251
|
const dests = listDestinations(cfg).filter((d) => d.type === 'api' && d.baseUrl);
|
|
162
252
|
await Promise.all(dests.map(async (d) => {
|
|
163
253
|
const ctrl = new AbortController();
|
|
@@ -178,10 +268,12 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
|
|
|
178
268
|
if (id && !seen.has(id)) {
|
|
179
269
|
seen.add(id);
|
|
180
270
|
const shape = apiShapeOf(d);
|
|
271
|
+
const blocked = unconfiguredReason(d);
|
|
181
272
|
base.data.push({
|
|
182
273
|
id, object: 'model', owned_by: d.id, provider: d.id,
|
|
183
274
|
provider_type: d.protocol === 'anthropic' ? 'anthropic' : 'openai',
|
|
184
275
|
api: shape.api, endpoints: shape.endpoints,
|
|
276
|
+
...(blocked ? { configured: false, reason: blocked } : {}),
|
|
185
277
|
});
|
|
186
278
|
}
|
|
187
279
|
}
|
package/src/server.js
CHANGED
|
@@ -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.66';
|
|
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 {
|