@mrrlin-dev/external-agents 0.3.3 → 0.3.4

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.
Files changed (3) hide show
  1. package/lib/dispatch.js +53 -0
  2. package/package.json +1 -1
  3. package/ui.js +56 -50
package/lib/dispatch.js CHANGED
@@ -180,6 +180,59 @@ export function resolveEscalation(registry, sourceAgentId) {
180
180
  * model: "gemini-3.5-flash" # OPTIONAL — falls back to agentEntry.model
181
181
  * output_filename: "generated.md" # OPTIONAL — default "generated.md"
182
182
  */
183
+ // Lightweight credential verifier — a real API round-trip that proves the key
184
+ // works, without the workdir/JSONL overhead of runGenerate. Used by the UI's
185
+ // paste-and-save flow to give the operator immediate feedback ("✓ verified"
186
+ // vs "invalid key — 401") instead of just "env var is set".
187
+ //
188
+ // Returns { ok: true, latencyMs } on 2xx, or { ok: false, status, hint } on
189
+ // anything else. Times out at 10s (verification is a UX detail, not a job).
190
+ export async function verifyCredential(agentEntry) {
191
+ const g = agentEntry.transports?.generate_new;
192
+ if (!g || !g.url) return { ok: false, hint: "no generate_new transport to verify against" };
193
+ const envName = g.env;
194
+ if (envName && envName !== "OLLAMA_UNUSED_KEY") {
195
+ const apiKey = process.env[envName];
196
+ if (!apiKey) return { ok: false, hint: `env var ${envName} not set` };
197
+ }
198
+ const apiKey = envName && envName !== "OLLAMA_UNUSED_KEY" ? process.env[envName] : null;
199
+ const model = g.model || agentEntry.model;
200
+
201
+ const ctrl = new AbortController();
202
+ const timer = setTimeout(() => ctrl.abort(), 10000);
203
+ const start = Date.now();
204
+ try {
205
+ const headers = { "Content-Type": "application/json" };
206
+ if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
207
+ const resp = await fetch(g.url, {
208
+ method: "POST",
209
+ headers,
210
+ body: JSON.stringify({
211
+ model,
212
+ messages: [{ role: "user", content: "ping" }],
213
+ max_tokens: 1,
214
+ stream: false,
215
+ }),
216
+ signal: ctrl.signal,
217
+ });
218
+ const latencyMs = Date.now() - start;
219
+ if (resp.ok) return { ok: true, latencyMs };
220
+ const text = await resp.text().catch(() => "");
221
+ return {
222
+ ok: false,
223
+ status: resp.status,
224
+ hint: resp.status === 401 || resp.status === 403
225
+ ? "invalid API key (server returned " + resp.status + ")"
226
+ : `HTTP ${resp.status}` + (text ? ": " + text.slice(0, 200) : ""),
227
+ latencyMs,
228
+ };
229
+ } catch (e) {
230
+ return { ok: false, hint: e.name === "AbortError" ? "timeout after 10s" : e.message };
231
+ } finally {
232
+ clearTimeout(timer);
233
+ }
234
+ }
235
+
183
236
  export async function runGenerate(agentEntry, prompt, options = {}) {
184
237
  const g = agentEntry.transports?.generate_new;
185
238
  if (!g || typeof g !== "object") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrrlin-dev/external-agents",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "One MCP server for every LLM you talk to \u2014 direct-API dispatcher across Gemini, DeepSeek, Groq, OpenRouter, Cerebras, and more. Part of mrrlin.com.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/ui.js CHANGED
@@ -6,6 +6,7 @@ import os from "node:os";
6
6
  import path from "node:path";
7
7
  import { loadRegistry } from "./lib/registry.js";
8
8
  import { readState, writeState, probeInstalled } from "./lib/state.js";
9
+ import { verifyCredential } from "./lib/dispatch.js";
9
10
 
10
11
  // UI-set persisted keys — MUST use the same file that server.js reads at
11
12
  // startup, otherwise the operator's saved key is invisible on next boot.
@@ -177,7 +178,7 @@ const PAGE = `<!doctype html>
177
178
 
178
179
  <div style="margin-top: 32px; padding: 16px; background: #fff; border: 1px dashed #ccc; border-radius: 4px;">
179
180
  <h3 style="margin: 0 0 4px 0; font-size: 15px;">Missing your model?</h3>
180
- <p style="margin: 0 0 12px 0; color: #666; font-size: 13px;">Suggest a new model or provider &mdash; opens a pre-filled issue on the public tracker at <a href="https://github.com/mrrlin-dev/external-agents/issues" target="_blank" rel="noopener noreferrer">mrrlin-dev/external-agents</a> (also logged locally).</p>
181
+ <p style="margin: 0 0 12px 0; color: #666; font-size: 13px;">Suggest a new model or provider &mdash; opens a pre-filled issue on the public tracker at <a href="https://github.com/mrrlin-dev/external-agents/issues" target="_blank" rel="noopener noreferrer">mrrlin-dev/external-agents</a>.</p>
181
182
  <div style="display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
182
183
  <input id="suggest-name" placeholder="Model or provider name (e.g. anthropic/haiku-4-5)" style="flex: 1; min-width: 260px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font: inherit;">
183
184
  <input id="suggest-url" placeholder="Docs / setup URL (optional)" style="flex: 1; min-width: 260px; padding: 6px 10px; border: 1px solid #ccc; border-radius: 4px; font: inherit;">
@@ -224,18 +225,10 @@ async function submitSuggest() {
224
225
  out.textContent = "opening GitHub issue…";
225
226
  out.style.color = "#666";
226
227
 
227
- // 1) Fire-and-forget local JSONL record so 'external-agents ui' still has an
228
- // audit trail even if the user cancels the GitHub tab.
229
- fetch("/api/suggest", {
230
- method: "POST",
231
- headers: { "Content-Type": "application/json" },
232
- body: JSON.stringify({ name, url }),
233
- }).catch(() => { /* non-blocking; the public issue below is the real submit */ });
234
-
235
- // 2) Public path — open a pre-filled New Issue on the external-agents repo.
236
- // The registry maintainer picks it up from the public tracker; anyone else
237
- // watching the repo can see it too, so proposals are discoverable, not stuck
238
- // in a private JSONL.
228
+ // Open a pre-filled New Issue on the public tracker. The registry
229
+ // maintainer picks it up from GitHub; anyone else watching the repo can see
230
+ // it too, so proposals are discoverable and trackable without a local
231
+ // JSONL sidecar (which nobody ever reads).
239
232
  const body = [
240
233
  "**Model / provider:** " + name,
241
234
  "",
@@ -252,8 +245,7 @@ async function submitSuggest() {
252
245
  window.open(issueUrl, "_blank", "noopener,noreferrer");
253
246
 
254
247
  out.innerHTML = "opened a pre-filled GitHub issue in a new tab — " +
255
- "just click <b>Submit new issue</b> there. " +
256
- '(also logged locally as backup)';
248
+ "just click <b>Submit new issue</b> there.";
257
249
  out.style.color = "#4a8";
258
250
  document.getElementById("suggest-name").value = "";
259
251
  document.getElementById("suggest-url").value = "";
@@ -358,13 +350,20 @@ async function saveKey(envName) {
358
350
  });
359
351
  const j = await r.json();
360
352
  if (r.ok) {
353
+ const v = (j.verified || [])[0] || {};
361
354
  const nProbed = (j.reprobed || []).length;
362
- stat.textContent = "✓ persisted to keys.env" + (nProbed ? " · " + nProbed + " entries re-probed" : "");
363
- stat.className = "status";
355
+ if (v.ok) {
356
+ const ms = v.latencyMs ? " (" + v.latencyMs + "ms)" : "";
357
+ stat.innerHTML = "<span style=\\"color:#1a6b31;\\">✓ verified" + ms + "</span> — " + nProbed + " model" + (nProbed === 1 ? "" : "s") + " unlocked";
358
+ } else if (v.hint) {
359
+ stat.innerHTML = "<span style=\\"color:#9a2b1c;\\">✗ " + v.hint + "</span> — the key was saved but the provider rejected it";
360
+ } else {
361
+ stat.innerHTML = "<span style=\\"color:#1a6b31;\\">✓ persisted</span> — " + nProbed + " model" + (nProbed === 1 ? "" : "s") + " unlocked";
362
+ }
364
363
  inp.value = "";
365
364
  // Immediately refresh the table + banner so freshly-unlocked entries
366
- // drop from the banner without a manual page reload. State has already
367
- // been server-side re-probed by /api/set_credential.
365
+ // drop from the banner without a manual page reload. Verified failures
366
+ // remain in needs_auth so the banner keeps them visible.
368
367
  await refresh();
369
368
  } else {
370
369
  stat.textContent = "error: " + (j.error || r.statusText);
@@ -413,7 +412,7 @@ const server = http.createServer(async (req, res) => {
413
412
  if (req.method === "POST" && p === "/api/set_credential") {
414
413
  let body = "";
415
414
  req.on("data", (c) => { body += c.toString(); });
416
- req.on("end", () => {
415
+ req.on("end", async () => {
417
416
  try {
418
417
  const { env_name, value } = JSON.parse(body || "{}");
419
418
  if (!env_name || typeof env_name !== "string" || !/^[A-Z_][A-Z0-9_]*$/.test(env_name)) {
@@ -440,18 +439,51 @@ const server = http.createServer(async (req, res) => {
440
439
  const genVar = a.transports?.generate_new?.env || null;
441
440
  return authVar === env_name || genVar === env_name;
442
441
  });
442
+ // Two-stage state update:
443
+ // 1. probeInstalled — cheap sanity check (env var set, binary present)
444
+ // 2. verifyCredential — REAL API round-trip (~<10s, max_tokens=1)
445
+ // against ONE representative entry per (provider × env_name) so we
446
+ // confirm the pasted key actually works. Verifying every entry that
447
+ // shares the same env var is pointless — they auth the same way.
443
448
  const patch = {};
444
449
  for (const a of affected) {
445
450
  const r = probeInstalled(a);
446
451
  patch[a.id] = { ...r, checked: Math.floor(Date.now() / 1000) };
447
452
  }
453
+ // Pick one entry per provider to verify (they all share the same key).
454
+ const seenProviders = new Set();
455
+ const toVerify = affected.filter((a) => {
456
+ if (seenProviders.has(a.provider)) return false;
457
+ seenProviders.add(a.provider);
458
+ return a.transports?.generate_new?.url;
459
+ });
460
+ const verifyResults = await Promise.all(toVerify.map(async (a) => {
461
+ const v = await verifyCredential(a);
462
+ return { agent_id: a.id, provider: a.provider, ...v };
463
+ }));
464
+ // Propagate a failed verify to every entry in that provider — mark
465
+ // needs_auth with the failure hint so banner keeps it visible.
466
+ for (const vr of verifyResults) {
467
+ if (!vr.ok) {
468
+ for (const a of affected.filter((x) => x.provider === vr.provider)) {
469
+ patch[a.id] = {
470
+ state: "needs_auth",
471
+ note: `verify failed: ${vr.hint || "unknown"}`,
472
+ checked: Math.floor(Date.now() / 1000),
473
+ };
474
+ }
475
+ }
476
+ }
448
477
  if (Object.keys(patch).length > 0) writeState(patch);
449
- console.error(`external-agents ui: re-probed ${affected.length} entries after set_credential(${env_name}): ${affected.map((a) => a.id).join(", ")}`);
478
+ const okCount = verifyResults.filter((v) => v.ok).length;
479
+ const failCount = verifyResults.length - okCount;
480
+ console.error(`external-agents ui: set_credential(${env_name}) — re-probed ${affected.length}, verified ${verifyResults.length} providers (${okCount} ok, ${failCount} failed): ${verifyResults.map((v) => v.provider + "=" + (v.ok ? "ok" : "FAIL:" + v.hint)).join(", ")}`);
450
481
  return json(res, 200, {
451
482
  ok: true,
452
483
  env_name,
453
484
  persisted_to: KEYS_FILE,
454
485
  reprobed: affected.map((a) => a.id),
486
+ verified: verifyResults,
455
487
  restart_required: "Restart your MCP client (Claude Code / Codex) so IT reads keys.env too.",
456
488
  });
457
489
  } catch (e) {
@@ -461,35 +493,9 @@ const server = http.createServer(async (req, res) => {
461
493
  return;
462
494
  }
463
495
 
464
- if (req.method === "POST" && p === "/api/suggest") {
465
- let body = "";
466
- req.on("data", (c) => { body += c.toString(); });
467
- req.on("end", () => {
468
- try {
469
- const { name, url: docUrl } = JSON.parse(body || "{}");
470
- if (!name || typeof name !== "string") {
471
- return json(res, 400, { error: "missing 'name'" });
472
- }
473
- // In the standalone spike, we persist locally + log to stderr so operators
474
- // can see the request. Mrrlin consumers wire this endpoint into the
475
- // report-issue mechanism (see ADR 0021 § Consumer-side UX affordances).
476
- const id = "sug-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6);
477
- const entry = { id, ts: new Date().toISOString(), name: name.trim(), url: (docUrl || "").trim() };
478
- try {
479
- const dir = path.join(os.homedir(), ".local/state/external-agents");
480
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
481
- fs.appendFileSync(path.join(dir, "suggestions.jsonl"), JSON.stringify(entry) + "\n", { mode: 0o600 });
482
- } catch (e) {
483
- console.error(`external-agents ui: WARN — could not persist suggestion: ${e.message}`);
484
- }
485
- console.error(`external-agents ui: suggestion recorded → id=${id} name="${entry.name}"${entry.url ? " url=" + entry.url : ""}`);
486
- return json(res, 200, { ok: true, id });
487
- } catch (e) {
488
- return json(res, 400, { error: "invalid json: " + e.message });
489
- }
490
- });
491
- return;
492
- }
496
+ // NB: /api/suggest was removed the "Missing your model?" form now opens
497
+ // a pre-filled GitHub issue on the public tracker directly (no local write
498
+ // needed, and the local JSONL was write-only, never read).
493
499
 
494
500
  if ((req.method === "GET" || req.method === "POST") && p === "/api/probe") {
495
501
  const id = parsed.query.id;