@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.64",
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.64';
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 KNOWN_AGENTS.has(model) ? model : cfg.bridge.agent;
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
- const turn = { bridgeUrl: cfg.bridge.url, agent, token, messages, system, signal: ac.signal };
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 {