@chatpanel/gateway 0.6.64 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.64",
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/router.js CHANGED
@@ -112,6 +112,44 @@ export function aggregateModels(cfg) {
112
112
  return { object: 'list', data };
113
113
  }
114
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
+
115
153
  /** id → installed, from the bridge's own /health. `null` when it could not be asked. */
116
154
  async function bridgeAgentAvailability(cfg, timeoutMs) {
117
155
  const base = String(cfg?.bridge?.url || '').replace(/\/$/, '');
@@ -158,6 +196,27 @@ export async function aggregateModelsAsync(cfg, { timeoutMs = 4000 } = {}) {
158
196
  }
159
197
  }
160
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
+
161
220
  const dests = listDestinations(cfg).filter((d) => d.type === 'api' && d.baseUrl);
162
221
  await Promise.all(dests.map(async (d) => {
163
222
  const ctrl = new AbortController();
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.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 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 {