@mrrlin-dev/external-agents 0.3.3 → 0.3.5
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/cli.js +13 -3
- package/lib/dispatch.js +53 -0
- package/package.json +1 -1
- package/ui.js +56 -50
package/cli.js
CHANGED
|
@@ -227,10 +227,20 @@ function cmdInit(flags) {
|
|
|
227
227
|
const child = spawn(process.execPath, [uiPath], { stdio: "inherit", env });
|
|
228
228
|
// Give the UI ~600ms to bind before opening the browser (loopback listen is
|
|
229
229
|
// usually instantaneous but we do not want the browser to open on a not-yet-
|
|
230
|
-
// bound port).
|
|
230
|
+
// bound port). Browser-open is best-effort — swallow BOTH sync spawn errors
|
|
231
|
+
// AND async 'error' events (ENOENT is emitted async, not thrown; without a
|
|
232
|
+
// listener it crashes the process — this is what breaks curl|bash on a
|
|
233
|
+
// headless Linux box that has no xdg-open installed). The UI keeps running.
|
|
231
234
|
setTimeout(() => {
|
|
232
|
-
try {
|
|
233
|
-
|
|
235
|
+
try {
|
|
236
|
+
const opener_proc = spawn(opener, openerArgs, { stdio: "ignore", detached: true });
|
|
237
|
+
opener_proc.on("error", (err) => {
|
|
238
|
+
console.error(`external-agents init: could not launch browser (${err.code || err.message}) — open ${url} manually.`);
|
|
239
|
+
});
|
|
240
|
+
opener_proc.unref();
|
|
241
|
+
} catch (err) {
|
|
242
|
+
console.error(`external-agents init: could not launch browser (${err.message}) — open ${url} manually.`);
|
|
243
|
+
}
|
|
234
244
|
}, 600);
|
|
235
245
|
child.on("exit", (code) => process.exit(code ?? 0));
|
|
236
246
|
process.on("SIGINT", () => child.kill("SIGINT"));
|
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
|
+
"version": "0.3.5",
|
|
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 — 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
|
|
181
|
+
<p style="margin: 0 0 12px 0; color: #666; font-size: 13px;">Suggest a new model or provider — 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
|
-
//
|
|
228
|
-
//
|
|
229
|
-
|
|
230
|
-
|
|
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
|
-
|
|
363
|
-
|
|
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.
|
|
367
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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;
|