@hypabolic/crossbar 0.1.0
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/ARCHITECTURE.md +168 -0
- package/CAPABILITY-MATRIX.md +49 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/RESEARCH.md +343 -0
- package/package.json +53 -0
- package/src/adapters/anthropic.ts +197 -0
- package/src/adapters/generic.ts +164 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/llamacpp.ts +217 -0
- package/src/adapters/llamaswap.ts +276 -0
- package/src/adapters/lmstudio.ts +307 -0
- package/src/adapters/ollama.ts +340 -0
- package/src/adapters/openai.ts +195 -0
- package/src/adapters/vllm.ts +197 -0
- package/src/core/backend-adapter.ts +123 -0
- package/src/core/capability.ts +53 -0
- package/src/core/index.ts +36 -0
- package/src/core/types.ts +160 -0
- package/src/discovery/engine.ts +247 -0
- package/src/discovery/probe.ts +144 -0
- package/src/index.ts +158 -0
- package/src/registry/ids.ts +68 -0
- package/src/registry/persistence.ts +111 -0
- package/src/registry/pi-credential-store.ts +27 -0
- package/src/registry/registry.ts +150 -0
- package/src/shim/provider-shim.ts +187 -0
- package/src/ui/loaded-widget.ts +220 -0
- package/src/ui/onboarding.ts +439 -0
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossbar onboarding overlay — the `/crossbar` in-TUI setup flow.
|
|
3
|
+
*
|
|
4
|
+
* Exports:
|
|
5
|
+
* - Pure, unit-testable helpers:
|
|
6
|
+
* buildDiscoveredItems — SelectItem[] from discovered servers + existing registry
|
|
7
|
+
* buildModelItems — SelectItem[] from ModelDescriptor[]
|
|
8
|
+
* capabilityActions — capability-filtered action list
|
|
9
|
+
* normalizeManualUrl — coerce bare host:port / missing-scheme inputs to a valid origin
|
|
10
|
+
*
|
|
11
|
+
* - The flow driver:
|
|
12
|
+
* openOnboarding — ctx.ui.custom overlay: discover → pick → (manual add) → test → model → save
|
|
13
|
+
*
|
|
14
|
+
* HARD RULES (mirrored from ARCHITECTURE.md):
|
|
15
|
+
* - Never log or serialize the API key the user enters.
|
|
16
|
+
* - No raw ANSI — all styling through theme.fg(token, ...).
|
|
17
|
+
* - No new dependencies; only the injected Probe (via createProbe) for connection tests.
|
|
18
|
+
* - Do NOT modify src/core/, src/adapters/, src/registry/, or any other frozen modules.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
import { DynamicBorder, getSelectListTheme } from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import { Container, type SelectItem, SelectList, Text, matchesKey } from "@earendil-works/pi-tui";
|
|
24
|
+
|
|
25
|
+
import type { BackendAdapter } from "../core/backend-adapter.ts";
|
|
26
|
+
import { canIntrospect, canLoadUnload, canSwitch } from "../core/backend-adapter.ts";
|
|
27
|
+
import type { DiscoveredServer, ModelDescriptor, ServerRecord } from "../core/types.ts";
|
|
28
|
+
import type { ServerRegistry } from "../registry/registry.ts";
|
|
29
|
+
import { serverId } from "../registry/ids.ts";
|
|
30
|
+
import { adapterFor } from "../adapters/index.ts";
|
|
31
|
+
import { createProbe } from "../discovery/probe.ts";
|
|
32
|
+
|
|
33
|
+
// ─── Pure helpers ────────────────────────────────────────────────────────────
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Build a `SelectItem[]` representing the discovered servers for the top-level
|
|
37
|
+
* onboarding list. Already-registered servers are marked with a "(added)" suffix
|
|
38
|
+
* so the user can see what is new vs. what Crossbar already knows about.
|
|
39
|
+
*
|
|
40
|
+
* Items are ordered: discovered servers first (in discovery order), then a
|
|
41
|
+
* sentinel "Add manually" entry at the end.
|
|
42
|
+
*/
|
|
43
|
+
export function buildDiscoveredItems(
|
|
44
|
+
discovered: DiscoveredServer[],
|
|
45
|
+
existing: ServerRecord[],
|
|
46
|
+
): SelectItem[] {
|
|
47
|
+
const existingIds = new Set(existing.map((r) => r.id));
|
|
48
|
+
|
|
49
|
+
const items: SelectItem[] = discovered.map((server): SelectItem => {
|
|
50
|
+
const id = serverId(server.kind, server.baseUrl);
|
|
51
|
+
const isAdded = existingIds.has(id);
|
|
52
|
+
|
|
53
|
+
// Extract host:port from baseUrl for the label suffix
|
|
54
|
+
let hostPort: string;
|
|
55
|
+
try {
|
|
56
|
+
const u = new URL(server.baseUrl);
|
|
57
|
+
hostPort = `${u.hostname}:${u.port || (u.protocol === "https:" ? "443" : "80")}`;
|
|
58
|
+
} catch {
|
|
59
|
+
hostPort = server.baseUrl.replace(/^https?:\/\//, "");
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Compose a label: "[kind] host:port ✓ healthy" or "(added)"
|
|
63
|
+
const kindLabel = server.kind.charAt(0).toUpperCase() + server.kind.slice(1);
|
|
64
|
+
const healthMark = isAdded ? "(added)" : "✓ healthy";
|
|
65
|
+
const label = `${kindLabel} (${hostPort})`;
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
value: server.baseUrl,
|
|
69
|
+
label: isAdded ? `${label} (added)` : label,
|
|
70
|
+
description: isAdded
|
|
71
|
+
? `Already registered · ${healthMark}`
|
|
72
|
+
: `${healthMark} · auth: ${server.auth}${server.version ? ` · v${server.version}` : ""}`,
|
|
73
|
+
};
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Always append the manual-add sentinel
|
|
77
|
+
items.push({
|
|
78
|
+
value: "__manual__",
|
|
79
|
+
label: "+ Add server…",
|
|
80
|
+
description: "Enter a URL manually",
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
return items;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Build a `SelectItem[]` from a list of ModelDescriptors for the model-picker
|
|
88
|
+
* step of the onboarding flow.
|
|
89
|
+
*
|
|
90
|
+
* The description line surfaces the context window (when known) and any
|
|
91
|
+
* capability badges (vision, tools, reasoning, embeddings).
|
|
92
|
+
*/
|
|
93
|
+
export function buildModelItems(models: ModelDescriptor[]): SelectItem[] {
|
|
94
|
+
return models.map((m): SelectItem => {
|
|
95
|
+
const parts: string[] = [];
|
|
96
|
+
|
|
97
|
+
if (m.contextWindow !== undefined) {
|
|
98
|
+
const ctx =
|
|
99
|
+
m.contextWindow >= 1000
|
|
100
|
+
? `${Math.round(m.contextWindow / 1000)}k ctx`
|
|
101
|
+
: `${m.contextWindow} ctx`;
|
|
102
|
+
parts.push(ctx);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const caps: string[] = [];
|
|
106
|
+
if (m.reasoning) caps.push("reasoning");
|
|
107
|
+
if (m.tools) caps.push("tools");
|
|
108
|
+
if (m.input.includes("image")) caps.push("vision");
|
|
109
|
+
if (m.embeddings) caps.push("embeddings");
|
|
110
|
+
if (caps.length > 0) parts.push(caps.join(" · "));
|
|
111
|
+
|
|
112
|
+
const item: SelectItem = {
|
|
113
|
+
value: m.id,
|
|
114
|
+
label: m.name || m.id,
|
|
115
|
+
};
|
|
116
|
+
if (parts.length > 0) {
|
|
117
|
+
item.description = parts.join(" ");
|
|
118
|
+
}
|
|
119
|
+
return item;
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Return only the actions that `adapter` actually supports — capability-driven
|
|
125
|
+
* hiding in its simplest form.
|
|
126
|
+
*
|
|
127
|
+
* - "switch" requires SwitchModel (present on Ollama, LM Studio, llama-swap)
|
|
128
|
+
* - "load" requires LoadUnload (present on Ollama, LM Studio)
|
|
129
|
+
* - "unload" requires LoadUnload
|
|
130
|
+
* - "introspect" requires IntrospectLoaded (present on Ollama, LM Studio)
|
|
131
|
+
*
|
|
132
|
+
* vLLM, OpenAI, Anthropic, and the generic adapter all lack these, so the
|
|
133
|
+
* returned list will be empty (or reduced) for them.
|
|
134
|
+
*/
|
|
135
|
+
export function capabilityActions(
|
|
136
|
+
adapter: BackendAdapter,
|
|
137
|
+
): { label: string; value: string }[] {
|
|
138
|
+
const actions: { label: string; value: string }[] = [];
|
|
139
|
+
|
|
140
|
+
if (canSwitch(adapter)) {
|
|
141
|
+
actions.push({ label: "Switch model", value: "switch" });
|
|
142
|
+
}
|
|
143
|
+
if (canLoadUnload(adapter)) {
|
|
144
|
+
actions.push({ label: "Load model", value: "load" });
|
|
145
|
+
actions.push({ label: "Unload model", value: "unload" });
|
|
146
|
+
}
|
|
147
|
+
if (canIntrospect(adapter)) {
|
|
148
|
+
actions.push({ label: "Inspect loaded models", value: "introspect" });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return actions;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Coerce a user-supplied string (which may be bare "host:port", missing a scheme,
|
|
156
|
+
* or already a valid URL) into a well-formed origin with no trailing slash.
|
|
157
|
+
*
|
|
158
|
+
* Rules applied in order:
|
|
159
|
+
* 1. Trim whitespace.
|
|
160
|
+
* 2. If input already starts with "http://" or "https://", parse as-is.
|
|
161
|
+
* 3. If it looks like "host:port" (no "://"), prefix "http://".
|
|
162
|
+
* 4. Strip any path/query/fragment — we want only the origin.
|
|
163
|
+
* 5. Strip trailing slashes.
|
|
164
|
+
*
|
|
165
|
+
* Returns the coerced origin string. Throws if the result is not a valid URL.
|
|
166
|
+
*/
|
|
167
|
+
export function normalizeManualUrl(input: string): string {
|
|
168
|
+
let raw = input.trim();
|
|
169
|
+
|
|
170
|
+
// Already has a scheme → use as-is for parsing
|
|
171
|
+
if (!raw.startsWith("http://") && !raw.startsWith("https://")) {
|
|
172
|
+
raw = `http://${raw}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const u = new URL(raw); // throws DOMException / TypeError on invalid input
|
|
176
|
+
// Return only the origin (scheme + host + port), no path
|
|
177
|
+
return u.origin.replace(/\/+$/, "");
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ─── Overlay flow driver ────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
export interface OnboardingDeps {
|
|
183
|
+
registry: ServerRegistry;
|
|
184
|
+
discover: () => Promise<DiscoveredServer[]>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Open the Crossbar onboarding overlay.
|
|
189
|
+
*
|
|
190
|
+
* Flow:
|
|
191
|
+
* 1. Run discovery and show discovered servers + "Add manually" entry.
|
|
192
|
+
* 2a. If user picks a discovered server → test connection (health + listModels).
|
|
193
|
+
* 2b. If user picks "Add manually" → prompt for URL, optional API key, test.
|
|
194
|
+
* 3. Pick a default model from the server's model list.
|
|
195
|
+
* 4. Save to registry (+ auth.json for api keys).
|
|
196
|
+
*
|
|
197
|
+
* The driver is intentionally thin: item building is delegated to the pure helpers
|
|
198
|
+
* above, and connection testing goes through the same `createProbe` + adapter
|
|
199
|
+
* `health`/`listModels` pair that production and the conformance tests use.
|
|
200
|
+
*
|
|
201
|
+
* @param pi - ExtensionAPI (needed to honour the ExtensionCommandContext signature for commands)
|
|
202
|
+
* @param ctx - ExtensionCommandContext from the `/crossbar` command handler
|
|
203
|
+
* @param deps - injected registry + discover function (for testability)
|
|
204
|
+
*/
|
|
205
|
+
export async function openOnboarding(
|
|
206
|
+
_pi: ExtensionAPI,
|
|
207
|
+
ctx: ExtensionCommandContext,
|
|
208
|
+
deps: OnboardingDeps,
|
|
209
|
+
): Promise<void> {
|
|
210
|
+
const { registry, discover } = deps;
|
|
211
|
+
|
|
212
|
+
// ── Step 0: run discovery (non-blocking for the overlay; we show a spinner) ──
|
|
213
|
+
ctx.ui.notify("Crossbar: scanning localhost for backends…", "info");
|
|
214
|
+
|
|
215
|
+
let discovered: DiscoveredServer[] = [];
|
|
216
|
+
try {
|
|
217
|
+
discovered = await discover();
|
|
218
|
+
} catch {
|
|
219
|
+
// Discovery failures are non-fatal — the user can still add manually
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const existing = registry.list();
|
|
223
|
+
|
|
224
|
+
// ── Step 1: show top-level discovery list ──────────────────────────────────
|
|
225
|
+
const topItems = buildDiscoveredItems(discovered, existing);
|
|
226
|
+
|
|
227
|
+
const chosenBaseUrl = await ctx.ui.custom<string | null>(
|
|
228
|
+
(_tui, theme, _kb, done) => {
|
|
229
|
+
const container = new Container();
|
|
230
|
+
|
|
231
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
232
|
+
container.addChild(
|
|
233
|
+
new Text(theme.fg("accent", theme.bold("Crossbar — Local Model Servers"))),
|
|
234
|
+
);
|
|
235
|
+
container.addChild(
|
|
236
|
+
new Text(theme.fg("muted", "Select a discovered server or add one manually.")),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
const list = new SelectList(
|
|
240
|
+
topItems,
|
|
241
|
+
Math.min(topItems.length, 12),
|
|
242
|
+
getSelectListTheme(),
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
list.onSelect = (item) => done(item.value);
|
|
246
|
+
list.onCancel = () => done(null);
|
|
247
|
+
|
|
248
|
+
container.addChild(list);
|
|
249
|
+
container.addChild(
|
|
250
|
+
new Text(theme.fg("dim", "↑↓ navigate · Enter select · Esc cancel")),
|
|
251
|
+
);
|
|
252
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
render: (width: number) => container.render(width),
|
|
256
|
+
invalidate: () => container.invalidate(),
|
|
257
|
+
handleInput: (data: string) => {
|
|
258
|
+
list.handleInput(data);
|
|
259
|
+
_tui.requestRender();
|
|
260
|
+
},
|
|
261
|
+
};
|
|
262
|
+
},
|
|
263
|
+
{ overlay: true, overlayOptions: { width: "60%" } },
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
if (!chosenBaseUrl) return; // user cancelled
|
|
267
|
+
|
|
268
|
+
// ── Step 2: manual add branch ──────────────────────────────────────────────
|
|
269
|
+
let targetBaseUrl: string;
|
|
270
|
+
let manualApiKey: string | undefined;
|
|
271
|
+
let discoveredServer: DiscoveredServer | undefined;
|
|
272
|
+
|
|
273
|
+
if (chosenBaseUrl === "__manual__") {
|
|
274
|
+
// 2a. Ask for URL
|
|
275
|
+
const rawUrl = await ctx.ui.input("Server URL", "e.g. localhost:11434 or http://192.168.1.5:8080");
|
|
276
|
+
if (!rawUrl) return;
|
|
277
|
+
|
|
278
|
+
let normalizedUrl: string;
|
|
279
|
+
try {
|
|
280
|
+
normalizedUrl = normalizeManualUrl(rawUrl);
|
|
281
|
+
} catch {
|
|
282
|
+
ctx.ui.notify("Crossbar: invalid URL — could not parse.", "error");
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 2b. Ask for API key (with no-auth option)
|
|
287
|
+
const authChoice = await ctx.ui.select(
|
|
288
|
+
"Authentication",
|
|
289
|
+
["No authentication (open server)", "Enter API key"],
|
|
290
|
+
);
|
|
291
|
+
if (authChoice === undefined) return;
|
|
292
|
+
|
|
293
|
+
if (authChoice === "Enter API key") {
|
|
294
|
+
const key = await ctx.ui.input("API key", "Paste your key (hidden after this dialog)");
|
|
295
|
+
if (key === undefined) return;
|
|
296
|
+
// key may be empty string if user submitted blank — treat as no-auth
|
|
297
|
+
manualApiKey = key.length > 0 ? key : undefined;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
targetBaseUrl = normalizedUrl;
|
|
301
|
+
} else {
|
|
302
|
+
// Discovered server path
|
|
303
|
+
discoveredServer = discovered.find((s) => s.baseUrl === chosenBaseUrl);
|
|
304
|
+
targetBaseUrl = chosenBaseUrl;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ── Step 3: fingerprint (if we don't already have a DiscoveredServer) ─────
|
|
308
|
+
if (!discoveredServer) {
|
|
309
|
+
ctx.ui.notify("Crossbar: testing connection…", "info");
|
|
310
|
+
|
|
311
|
+
const cred =
|
|
312
|
+
manualApiKey !== undefined
|
|
313
|
+
? { mode: "apiKey" as const, apiKey: manualApiKey }
|
|
314
|
+
: { mode: "none" as const };
|
|
315
|
+
|
|
316
|
+
const probe = createProbe(targetBaseUrl, { auth: cred, defaultTimeoutMs: 3000 });
|
|
317
|
+
|
|
318
|
+
// Try each non-cloud adapter in probe order; first match wins
|
|
319
|
+
const { DISCOVERY_ADAPTERS } = await import("../adapters/index.ts");
|
|
320
|
+
for (const adapter of DISCOVERY_ADAPTERS) {
|
|
321
|
+
try {
|
|
322
|
+
const result = await adapter.fingerprint(targetBaseUrl, probe);
|
|
323
|
+
if (result) {
|
|
324
|
+
discoveredServer = result;
|
|
325
|
+
// Propagate auth mode from the user's input
|
|
326
|
+
if (manualApiKey !== undefined) {
|
|
327
|
+
discoveredServer = { ...result, auth: "apiKey" };
|
|
328
|
+
}
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
} catch {
|
|
332
|
+
// fingerprint failures are non-fatal
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!discoveredServer) {
|
|
337
|
+
ctx.ui.notify(
|
|
338
|
+
"Crossbar: could not identify the server — check the URL and try again.",
|
|
339
|
+
"error",
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ── Step 4: list models ────────────────────────────────────────────────────
|
|
346
|
+
ctx.ui.notify(`Crossbar: connected to ${discoveredServer.label} — fetching models…`, "info");
|
|
347
|
+
|
|
348
|
+
const adapter = adapterFor(discoveredServer.kind);
|
|
349
|
+
const cred =
|
|
350
|
+
manualApiKey !== undefined
|
|
351
|
+
? { mode: "apiKey" as const, apiKey: manualApiKey }
|
|
352
|
+
: { mode: "none" as const };
|
|
353
|
+
|
|
354
|
+
const probe = createProbe(targetBaseUrl, { auth: cred, defaultTimeoutMs: 5000 });
|
|
355
|
+
|
|
356
|
+
let models: ModelDescriptor[] = [];
|
|
357
|
+
try {
|
|
358
|
+
models = await adapter.listModels(discoveredServer, cred, probe);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
361
|
+
ctx.ui.notify(`Crossbar: could not list models — ${msg}`, "error");
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
if (models.length === 0) {
|
|
366
|
+
ctx.ui.notify("Crossbar: server is reachable but returned no models.", "warning");
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Step 5: pick default model ─────────────────────────────────────────────
|
|
371
|
+
const modelItems = buildModelItems(models);
|
|
372
|
+
|
|
373
|
+
const chosenModelId = await ctx.ui.custom<string | null>(
|
|
374
|
+
(_tui, theme, _kb, done) => {
|
|
375
|
+
const container = new Container();
|
|
376
|
+
|
|
377
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
378
|
+
container.addChild(
|
|
379
|
+
new Text(theme.fg("accent", theme.bold(`Pick default model — ${discoveredServer!.label}`))),
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const list = new SelectList(
|
|
383
|
+
modelItems,
|
|
384
|
+
Math.min(modelItems.length, 12),
|
|
385
|
+
getSelectListTheme(),
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
list.onSelect = (item) => done(item.value);
|
|
389
|
+
list.onCancel = () => done(null);
|
|
390
|
+
|
|
391
|
+
container.addChild(list);
|
|
392
|
+
container.addChild(
|
|
393
|
+
new Text(theme.fg("dim", "↑↓ navigate · Enter select · Esc skip")),
|
|
394
|
+
);
|
|
395
|
+
container.addChild(new DynamicBorder((s) => theme.fg("accent", s)));
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
render: (width: number) => container.render(width),
|
|
399
|
+
invalidate: () => container.invalidate(),
|
|
400
|
+
handleInput: (data: string) => {
|
|
401
|
+
// Allow Esc to skip model selection
|
|
402
|
+
if (matchesKey(data, "escape")) {
|
|
403
|
+
done(null);
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
list.handleInput(data);
|
|
407
|
+
_tui.requestRender();
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
},
|
|
411
|
+
{ overlay: true, overlayOptions: { width: "60%" } },
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
// chosenModelId === null means the user skipped — still register the server
|
|
415
|
+
|
|
416
|
+
// ── Step 6: save to registry ───────────────────────────────────────────────
|
|
417
|
+
const id = serverId(discoveredServer.kind, targetBaseUrl);
|
|
418
|
+
const record: ServerRecord = {
|
|
419
|
+
id,
|
|
420
|
+
kind: discoveredServer.kind,
|
|
421
|
+
baseUrl: targetBaseUrl,
|
|
422
|
+
label: discoveredServer.label,
|
|
423
|
+
auth: discoveredServer.auth,
|
|
424
|
+
enabled: true,
|
|
425
|
+
addedAt: Date.now(),
|
|
426
|
+
...(chosenModelId !== null ? { lastKnownModels: models } : {}),
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
// Pass the api key separately so it goes through the registry → authStorage path
|
|
430
|
+
// (never written into crossbar.json)
|
|
431
|
+
await registry.add(record, manualApiKey);
|
|
432
|
+
|
|
433
|
+
const modelNote =
|
|
434
|
+
chosenModelId !== null ? ` Default model: ${chosenModelId}.` : "";
|
|
435
|
+
ctx.ui.notify(
|
|
436
|
+
`Crossbar: ${discoveredServer.label} added!${modelNote} It will appear in /model.`,
|
|
437
|
+
"info",
|
|
438
|
+
);
|
|
439
|
+
}
|