@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.
@@ -0,0 +1,247 @@
1
+ /**
2
+ * Crossbar discovery engine.
3
+ *
4
+ * Sweeps a set of origins (host × port) with each non-cloud adapter's `fingerprint()`, picks the
5
+ * highest-confidence match per origin, and returns the deduplicated list of discovered servers.
6
+ *
7
+ * Design decisions:
8
+ * - Bounded concurrency (default 8 parallel probes) to avoid overwhelming localhost or LAN.
9
+ * - Cloud adapters (openai, anthropic) are skipped — they are configured, not probed.
10
+ * - Tie-break: when two adapters return equal confidence, prefer the more specific one over
11
+ * "openai-generic" (the catch-all fallback).
12
+ * - Dedupe by normalised origin string (protocol + host + port, lower-cased, no trailing slash).
13
+ * - `discoverLan` is an opt-in variant that accepts an explicit host list; no mDNS.
14
+ */
15
+
16
+ import { CLOUD_KINDS } from "../core/capability.ts";
17
+ import type { BackendAdapter } from "../core/backend-adapter.ts";
18
+ import type { DiscoveredServer, Probe } from "../core/types.ts";
19
+ import { createProbe } from "./probe.ts";
20
+
21
+ /** Default localhost ports probed in order (from CAPABILITY-MATRIX.md). */
22
+ export const DEFAULT_PROBE_PORTS: readonly number[] = [
23
+ 11434, // Ollama
24
+ 1234, // LM Studio
25
+ 8080, // llama-server / llama-swap / llamafile
26
+ 8000, // vLLM
27
+ 5000, // TabbyAPI / oobabooga
28
+ 5001, // KoboldCpp
29
+ 1337, // Jan
30
+ ];
31
+
32
+ const DEFAULT_HOST = "127.0.0.1";
33
+ const DEFAULT_TIMEOUT_MS = 600;
34
+ const DEFAULT_CONCURRENCY = 8;
35
+
36
+ // ────────────────────────────────────────────────────────────────────────────────
37
+ // Internal helpers
38
+ // ────────────────────────────────────────────────────────────────────────────────
39
+
40
+ /** Normalise an origin to a consistent, dedupe-safe string. */
41
+ function normalizeOrigin(protocol: string, host: string, port: number): string {
42
+ return `${protocol}//${host}:${port}`;
43
+ }
44
+
45
+ /**
46
+ * Run at most `concurrency` tasks at a time from an iterable of async thunks, collecting results.
47
+ * Rejected tasks are silently dropped (individual probe errors are already caught inside `probe`).
48
+ */
49
+ async function runBounded<T>(
50
+ tasks: Array<() => Promise<T | null>>,
51
+ concurrency: number,
52
+ ): Promise<Array<T>> {
53
+ const results: Array<T> = [];
54
+ const queue = tasks.slice();
55
+
56
+ async function worker(): Promise<void> {
57
+ while (queue.length > 0) {
58
+ const task = queue.shift();
59
+ if (!task) break;
60
+ try {
61
+ const result = await task();
62
+ if (result !== null && result !== undefined) {
63
+ results.push(result);
64
+ }
65
+ } catch {
66
+ // Swallow; probe errors are already normalised to status:0 inside createProbe
67
+ }
68
+ }
69
+ }
70
+
71
+ const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker());
72
+ await Promise.all(workers);
73
+ return results;
74
+ }
75
+
76
+ /**
77
+ * Given all fingerprint results for a single origin, select the best candidate:
78
+ * 1. Highest confidence wins.
79
+ * 2. Among equal-confidence candidates, prefer a specific adapter over "openai-generic".
80
+ */
81
+ function selectBest(candidates: DiscoveredServer[]): DiscoveredServer | null {
82
+ if (candidates.length === 0) return null;
83
+
84
+ return candidates.reduce<DiscoveredServer>((best, candidate) => {
85
+ if (candidate.confidence > best.confidence) return candidate;
86
+ if (candidate.confidence === best.confidence) {
87
+ // Tie-break: demote openai-generic; keep the more specific one
88
+ if (best.kind === "openai-generic" && candidate.kind !== "openai-generic") {
89
+ return candidate;
90
+ }
91
+ }
92
+ return best;
93
+ }, candidates[0]!);
94
+ }
95
+
96
+ /** Builds the Probe used for one origin. Overridable for tests (default: real network probe). */
97
+ export type ProbeFactory = (origin: string, timeoutMs: number) => Probe;
98
+
99
+ /**
100
+ * Probe a single origin against every eligible adapter, return the best match or null.
101
+ * Exported so integration tests can exercise the real selection logic with a fake probe.
102
+ */
103
+ export async function probeOrigin(
104
+ origin: string,
105
+ adapters: BackendAdapter[],
106
+ timeoutMs: number,
107
+ probeFactory?: ProbeFactory,
108
+ ): Promise<DiscoveredServer | null> {
109
+ const probe = probeFactory
110
+ ? probeFactory(origin, timeoutMs)
111
+ : createProbe(origin, { defaultTimeoutMs: timeoutMs });
112
+
113
+ // Run all adapter fingerprints concurrently for this single origin
114
+ const fingerprintResults = await Promise.all(
115
+ adapters.map(async (adapter): Promise<DiscoveredServer | null> => {
116
+ try {
117
+ return await adapter.fingerprint(origin, probe);
118
+ } catch {
119
+ return null;
120
+ }
121
+ }),
122
+ );
123
+
124
+ const candidates = fingerprintResults.filter((r): r is DiscoveredServer => r !== null);
125
+ return selectBest(candidates);
126
+ }
127
+
128
+ // ────────────────────────────────────────────────────────────────────────────────
129
+ // Public API
130
+ // ────────────────────────────────────────────────────────────────────────────────
131
+
132
+ export interface DiscoverLocalhostOptions {
133
+ /** Override the default probe ports. */
134
+ ports?: number[];
135
+ /** Override the default host (127.0.0.1). */
136
+ host?: string;
137
+ /** Per-probe timeout in ms (default 600). */
138
+ timeoutMs?: number;
139
+ /** Abort signal to cancel a sweep in progress. */
140
+ signal?: AbortSignal;
141
+ /** Override how the per-origin Probe is built (tests inject a fake probe here). */
142
+ probeFactory?: ProbeFactory;
143
+ }
144
+
145
+ /**
146
+ * Sweep localhost (or a custom host) on the given ports, fingerprint each live origin, and return
147
+ * the deduplicated list of discovered servers — one per origin, highest-confidence adapter wins.
148
+ *
149
+ * Non-cloud adapters only; cloud adapters (openai, anthropic) are skipped — they are configured
150
+ * via Pi's own `/login`, not discovered by port sweeping.
151
+ */
152
+ export async function discoverLocalhost(
153
+ adapters: BackendAdapter[],
154
+ opts?: DiscoverLocalhostOptions,
155
+ ): Promise<DiscoveredServer[]> {
156
+ const ports = opts?.ports ?? DEFAULT_PROBE_PORTS;
157
+ const host = opts?.host ?? DEFAULT_HOST;
158
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
159
+ const signal = opts?.signal;
160
+
161
+ // Filter to non-cloud adapters only
162
+ const localAdapters = adapters.filter((a) => !CLOUD_KINDS.has(a.kind));
163
+ if (localAdapters.length === 0) return [];
164
+
165
+ const origins = ports.map((port) => normalizeOrigin("http:", host, port));
166
+
167
+ // Build per-origin tasks for bounded concurrency
168
+ const tasks = origins.map((origin) => async (): Promise<DiscoveredServer | null> => {
169
+ if (signal?.aborted) return null;
170
+ return probeOrigin(origin, localAdapters, timeoutMs, opts?.probeFactory);
171
+ });
172
+
173
+ const allMatches = await runBounded(tasks, DEFAULT_CONCURRENCY);
174
+
175
+ // Dedupe by normalised origin (the fingerprint may produce the same baseUrl from different paths)
176
+ const seen = new Set<string>();
177
+ const deduplicated: DiscoveredServer[] = [];
178
+ for (const server of allMatches) {
179
+ const key = server.baseUrl.toLowerCase().replace(/\/+$/, "");
180
+ if (!seen.has(key)) {
181
+ seen.add(key);
182
+ deduplicated.push(server);
183
+ }
184
+ }
185
+
186
+ return deduplicated;
187
+ }
188
+
189
+ export interface DiscoverLanOptions {
190
+ /** Per-probe timeout in ms (default 600). */
191
+ timeoutMs?: number;
192
+ /** Ports to probe on each host (defaults to DEFAULT_PROBE_PORTS). */
193
+ ports?: number[];
194
+ /** Abort signal to cancel a sweep in progress. */
195
+ signal?: AbortSignal;
196
+ /** Override how the per-origin Probe is built (tests inject a fake probe here). */
197
+ probeFactory?: ProbeFactory;
198
+ }
199
+
200
+ /**
201
+ * Opt-in LAN discovery. Probes an explicit list of hosts × ports — no mDNS, just active TCP
202
+ * connect attempts. Call only when `CrossbarSettings.lanDiscovery` is true and the user has
203
+ * supplied host ranges or an explicit list.
204
+ *
205
+ * Follows the same fingerprinting and deduplication logic as `discoverLocalhost`.
206
+ */
207
+ export async function discoverLan(
208
+ adapters: BackendAdapter[],
209
+ hosts: string[],
210
+ opts?: DiscoverLanOptions,
211
+ ): Promise<DiscoveredServer[]> {
212
+ if (hosts.length === 0) return [];
213
+
214
+ const ports = opts?.ports ?? DEFAULT_PROBE_PORTS;
215
+ const timeoutMs = opts?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
216
+ const signal = opts?.signal;
217
+
218
+ const localAdapters = adapters.filter((a) => !CLOUD_KINDS.has(a.kind));
219
+ if (localAdapters.length === 0) return [];
220
+
221
+ // Enumerate host × port origins
222
+ const origins: string[] = [];
223
+ for (const host of hosts) {
224
+ for (const port of ports) {
225
+ origins.push(normalizeOrigin("http:", host, port));
226
+ }
227
+ }
228
+
229
+ const tasks = origins.map((origin) => async (): Promise<DiscoveredServer | null> => {
230
+ if (signal?.aborted) return null;
231
+ return probeOrigin(origin, localAdapters, timeoutMs, opts?.probeFactory);
232
+ });
233
+
234
+ const allMatches = await runBounded(tasks, DEFAULT_CONCURRENCY);
235
+
236
+ const seen = new Set<string>();
237
+ const deduplicated: DiscoveredServer[] = [];
238
+ for (const server of allMatches) {
239
+ const key = server.baseUrl.toLowerCase().replace(/\/+$/, "");
240
+ if (!seen.has(key)) {
241
+ seen.add(key);
242
+ deduplicated.push(server);
243
+ }
244
+ }
245
+
246
+ return deduplicated;
247
+ }
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Real `Probe` factory for the Crossbar discovery engine.
3
+ *
4
+ * Wraps Node's global `fetch` with:
5
+ * - URL resolution of `path` against `baseUrl`
6
+ * - `Authorization: Bearer <key>` injection when `auth.mode === "apiKey"` (key never logged)
7
+ * - Per-request timeout via AbortController (default 600 ms)
8
+ * - Lossless error capture: refused / DNS / timeout → `{ status: 0, ok: false, error: "..." }`
9
+ * - Response body read into `text` + best-effort `json`
10
+ * - Lowercased `headers` map
11
+ * - `latencyMs` measurement
12
+ *
13
+ * No network I/O outside the returned `Probe` function; the factory itself is pure.
14
+ */
15
+
16
+ import type { Probe, ProbeInit, ProbeResult, ServerCredential } from "../core/types.ts";
17
+
18
+ export const DEFAULT_DISCOVERY_TIMEOUT_MS = 600;
19
+
20
+ export interface CreateProbeOptions {
21
+ auth?: ServerCredential;
22
+ defaultTimeoutMs?: number;
23
+ }
24
+
25
+ /**
26
+ * Build a `Probe` bound to `baseUrl`.
27
+ *
28
+ * @param baseUrl - Normalized server origin (no trailing slash). `path` values starting with `/`
29
+ * are resolved against this origin; otherwise they are treated as relative paths.
30
+ * @param opts - Optional auth credential and per-probe timeout override.
31
+ */
32
+ export function createProbe(baseUrl: string, opts?: CreateProbeOptions): Probe {
33
+ const defaultTimeout = opts?.defaultTimeoutMs ?? DEFAULT_DISCOVERY_TIMEOUT_MS;
34
+ const auth = opts?.auth;
35
+
36
+ return async function probe(path: string, init?: ProbeInit): Promise<ProbeResult> {
37
+ // ── URL resolution ──────────────────────────────────────────────────────────
38
+ // Normalise baseUrl to have no trailing slash, then join with the path.
39
+ const base = baseUrl.replace(/\/+$/, "");
40
+ const url = path.startsWith("http://") || path.startsWith("https://")
41
+ ? path
42
+ : `${base}${path.startsWith("/") ? path : `/${path}`}`;
43
+
44
+ // ── Request headers ─────────────────────────────────────────────────────────
45
+ const requestHeaders: Record<string, string> = {};
46
+
47
+ // Inject auth header when configured — key is never exposed in logs or results
48
+ if (auth?.mode === "apiKey" && auth.apiKey) {
49
+ requestHeaders["authorization"] = `Bearer ${auth.apiKey}`;
50
+ }
51
+
52
+ // Merge caller-supplied headers (allow override of everything except the auth key value,
53
+ // so callers can set content-type etc.)
54
+ if (init?.headers) {
55
+ for (const [k, v] of Object.entries(init.headers)) {
56
+ // Never let a caller accidentally expose the raw key by overriding authorization with it
57
+ requestHeaders[k.toLowerCase()] = v;
58
+ }
59
+ }
60
+
61
+ // ── Timeout ─────────────────────────────────────────────────────────────────
62
+ const timeoutMs = init?.timeoutMs ?? defaultTimeout;
63
+ const controller = new AbortController();
64
+ const timerId = setTimeout(() => controller.abort(), timeoutMs);
65
+
66
+ const startMs = Date.now();
67
+
68
+ try {
69
+ const fetchInit: RequestInit = {
70
+ method: init?.method ?? "GET",
71
+ headers: requestHeaders,
72
+ signal: controller.signal,
73
+ };
74
+ // Only set body when defined — exactOptionalPropertyTypes forbids undefined for BodyInit
75
+ if (init?.body !== undefined) {
76
+ fetchInit.body = init.body;
77
+ }
78
+
79
+ const response = await fetch(url, fetchInit);
80
+
81
+ const latencyMs = Date.now() - startMs;
82
+
83
+ // ── Collect response headers (lowercase keys) ────────────────────────────
84
+ const responseHeaders: Record<string, string> = {};
85
+ response.headers.forEach((value, key) => {
86
+ responseHeaders[key.toLowerCase()] = value;
87
+ });
88
+
89
+ // ── Read body ────────────────────────────────────────────────────────────
90
+ let text: string | undefined;
91
+ let json: unknown;
92
+
93
+ try {
94
+ text = await response.text();
95
+ } catch {
96
+ // Body read failure is non-fatal; leave text undefined
97
+ }
98
+
99
+ if (text !== undefined) {
100
+ try {
101
+ json = JSON.parse(text);
102
+ } catch {
103
+ // Not JSON; leave json undefined
104
+ }
105
+ }
106
+
107
+ const result: ProbeResult = {
108
+ status: response.status,
109
+ ok: response.ok,
110
+ headers: responseHeaders,
111
+ latencyMs,
112
+ };
113
+ // exactOptionalPropertyTypes: only set optional fields when they have a value
114
+ if (text !== undefined) result.text = text;
115
+ if (json !== undefined) result.json = json;
116
+ return result;
117
+ } catch (err: unknown) {
118
+ const latencyMs = Date.now() - startMs;
119
+
120
+ // Classify the failure — never include the api key in the error message
121
+ let errorMessage: string;
122
+ if (err instanceof Error) {
123
+ if (err.name === "AbortError") {
124
+ errorMessage = `Request timed out after ${timeoutMs}ms`;
125
+ } else {
126
+ // Scrub potential URL fragments that might contain keys; keep the message generic
127
+ errorMessage = err.message.replace(/authorization=[^\s&]*/gi, "authorization=[redacted]");
128
+ }
129
+ } else {
130
+ errorMessage = "Unknown fetch error";
131
+ }
132
+
133
+ return {
134
+ status: 0,
135
+ ok: false,
136
+ headers: {},
137
+ error: errorMessage,
138
+ latencyMs,
139
+ };
140
+ } finally {
141
+ clearTimeout(timerId);
142
+ }
143
+ };
144
+ }
package/src/index.ts ADDED
@@ -0,0 +1,158 @@
1
+ /**
2
+ * @hypabolic/crossbar — Pi extension entry point.
3
+ *
4
+ * Wires the frozen core + Wave A/B/C modules into Pi's lifecycle:
5
+ * session_start → load crossbar.json, register saved servers, auto-discover localhost,
6
+ * install the loaded-model widget, start the health poll.
7
+ * /crossbar → open the discovery / onboarding overlay (alias /local).
8
+ * session_shutdown → stop the poll, dispose the widget.
9
+ *
10
+ * Secrets live only in Pi's auth.json (via the CredentialStore bridge); crossbar.json holds metadata.
11
+ */
12
+
13
+ import type {
14
+ ExtensionAPI,
15
+ ExtensionCommandContext,
16
+ ExtensionContext,
17
+ } from "@earendil-works/pi-coding-agent";
18
+
19
+ import type { DiscoveredServer, ModelDescriptor, ServerRecord } from "./core/index.ts";
20
+ import { adapterFor, DISCOVERY_ADAPTERS } from "./adapters/index.ts";
21
+ import { discoverLocalhost } from "./discovery/engine.ts";
22
+ import { createProbe } from "./discovery/probe.ts";
23
+ import { loadConfig, saveConfig } from "./registry/persistence.ts";
24
+ import { createPiCredentialStore } from "./registry/pi-credential-store.ts";
25
+ import { serverId } from "./registry/ids.ts";
26
+ import { ServerRegistry } from "./registry/registry.ts";
27
+ import { registerServer } from "./shim/provider-shim.ts";
28
+ import { openOnboarding } from "./ui/onboarding.ts";
29
+ import { installLoadedWidget, type LoadedWidgetHandle } from "./ui/loaded-widget.ts";
30
+
31
+ const HEALTH_POLL_MS = 15_000;
32
+
33
+ /** Minimal DiscoveredServer reconstructed from a persisted record for adapter calls. */
34
+ function recordToServer(record: ServerRecord): DiscoveredServer {
35
+ return {
36
+ kind: record.kind,
37
+ baseUrl: record.baseUrl,
38
+ auth: record.auth,
39
+ label: record.label,
40
+ confidence: 1,
41
+ };
42
+ }
43
+
44
+ export default function crossbar(pi: ExtensionAPI): void {
45
+ let registry: ServerRegistry | undefined;
46
+ let widget: LoadedWidgetHandle | undefined;
47
+ let pollTimer: ReturnType<typeof setInterval> | undefined;
48
+
49
+ const discover = (): Promise<DiscoveredServer[]> => discoverLocalhost([...DISCOVERY_ADAPTERS]);
50
+
51
+ /** Best-effort: refresh a server's model list and (re)register it with Pi. Returns models used. */
52
+ async function refreshAndRegister(reg: ServerRegistry, record: ServerRecord): Promise<number> {
53
+ const adapter = adapterFor(record.kind);
54
+ const cred = await reg.resolveCredential(record);
55
+ let models: ModelDescriptor[] = record.lastKnownModels ?? [];
56
+ try {
57
+ const probe = createProbe(record.baseUrl, { auth: cred });
58
+ models = await adapter.listModels(recordToServer(record), cred, probe);
59
+ reg.updateHealthCache(record.id, { models, lastSeenAt: Date.now() });
60
+ } catch {
61
+ // Offline / unreachable — fall back to last-known models (may be empty).
62
+ }
63
+ if (models.length === 0) return 0; // nothing registrable (server offline, no cache)
64
+ await registerServer(pi, reg, record, models);
65
+ return models.length;
66
+ }
67
+
68
+ pi.on("session_start", async (_event, ctx: ExtensionContext) => {
69
+ // Rebuild cleanly on every start/reload.
70
+ if (pollTimer) {
71
+ clearInterval(pollTimer);
72
+ pollTimer = undefined;
73
+ }
74
+
75
+ const store = createPiCredentialStore(ctx.modelRegistry.authStorage);
76
+ const reg = new ServerRegistry({ store, persist: (cfg) => saveConfig(cfg) });
77
+ reg.load(await loadConfig());
78
+ registry = reg;
79
+
80
+ // 1) Register every enabled server from the saved config.
81
+ for (const record of reg.list()) {
82
+ if (!record.enabled) continue;
83
+ try {
84
+ await refreshAndRegister(reg, record);
85
+ } catch {
86
+ // Never let one bad server abort startup.
87
+ }
88
+ }
89
+
90
+ // 2) Auto-discover localhost; auto-register reachable no-auth servers, prompt for keyed ones.
91
+ try {
92
+ const found = await discover();
93
+ for (const srv of found) {
94
+ const id = serverId(srv.kind, srv.baseUrl);
95
+ if (reg.get(id)) continue; // already known
96
+ if (srv.auth !== "none") {
97
+ if (ctx.hasUI) {
98
+ ctx.ui.notify(`Crossbar: found ${srv.label} (needs an API key) — run /crossbar to add it.`, "info");
99
+ }
100
+ continue;
101
+ }
102
+ const record: ServerRecord = {
103
+ id,
104
+ kind: srv.kind,
105
+ baseUrl: srv.baseUrl,
106
+ label: srv.label,
107
+ auth: "none",
108
+ enabled: true,
109
+ addedAt: Date.now(),
110
+ lastSeenAt: Date.now(),
111
+ };
112
+ await reg.add(record);
113
+ const count = await refreshAndRegister(reg, record);
114
+ if (ctx.hasUI && count > 0) {
115
+ ctx.ui.notify(`Crossbar: registered ${srv.label} (${count} models).`, "info");
116
+ }
117
+ }
118
+ } catch {
119
+ // Discovery is best-effort; the user can always add servers via /crossbar.
120
+ }
121
+
122
+ // 3) Loaded-model widget + health poll (UI modes only).
123
+ if (ctx.hasUI) {
124
+ widget = installLoadedWidget(pi, ctx, reg);
125
+ await widget.refresh();
126
+ pollTimer = setInterval(() => {
127
+ void widget?.refresh();
128
+ }, HEALTH_POLL_MS);
129
+ }
130
+ });
131
+
132
+ pi.on("session_shutdown", async () => {
133
+ if (pollTimer) {
134
+ clearInterval(pollTimer);
135
+ pollTimer = undefined;
136
+ }
137
+ widget?.dispose();
138
+ widget = undefined;
139
+ });
140
+
141
+ const openCmd = async (_args: string, ctx: ExtensionCommandContext): Promise<void> => {
142
+ if (!registry) {
143
+ ctx.ui.notify("Crossbar is still initialising — try again in a moment.", "warning");
144
+ return;
145
+ }
146
+ await openOnboarding(pi, ctx, { registry, discover });
147
+ await widget?.refresh();
148
+ };
149
+
150
+ pi.registerCommand("crossbar", {
151
+ description: "Manage local & self-hosted model backends — discover, add, switch (Crossbar)",
152
+ handler: openCmd,
153
+ });
154
+ pi.registerCommand("local", {
155
+ description: "Alias for /crossbar",
156
+ handler: openCmd,
157
+ });
158
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Stable, deterministic server ID generation.
3
+ *
4
+ * IDs are derived from kind + host + port so the same physical server always gets the same id
5
+ * across restarts. The id is also used as the Pi provider name and as the auth.json key.
6
+ */
7
+
8
+ import { CLOUD_KINDS } from "../core/capability.ts";
9
+ import type { BackendKind } from "../core/capability.ts";
10
+
11
+ /**
12
+ * Sanitize a URL component to be safe for use in an id string.
13
+ * Replaces any character that isn't alphanumeric with a hyphen, collapses runs.
14
+ */
15
+ function sanitize(s: string): string {
16
+ return s.replace(/[^a-z0-9]+/gi, "-").replace(/^-+|-+$/g, "").toLowerCase();
17
+ }
18
+
19
+ /**
20
+ * Generate a stable id for a server of the given kind at the given base URL.
21
+ *
22
+ * Cloud kinds (openai, anthropic) have no meaningful host/port variation:
23
+ * → `crossbar-openai`, `crossbar-anthropic`
24
+ *
25
+ * Local kinds embed the host and port:
26
+ * → `crossbar-ollama-127-0-0-1-11434`
27
+ *
28
+ * The id is fully deterministic: same kind + baseUrl → same id every time.
29
+ */
30
+ export function serverId(kind: BackendKind, baseUrl: string): string {
31
+ if (CLOUD_KINDS.has(kind)) {
32
+ return `crossbar-${kind}`;
33
+ }
34
+
35
+ let host: string;
36
+ let port: string;
37
+ try {
38
+ const u = new URL(baseUrl);
39
+ host = u.hostname;
40
+ port = u.port || defaultPortForProtocol(u.protocol);
41
+ } catch {
42
+ // Fallback: sanitize the raw string
43
+ return `crossbar-${kind}-${sanitize(baseUrl)}`;
44
+ }
45
+
46
+ const hostPart = sanitize(host);
47
+ const portPart = sanitize(port);
48
+
49
+ const parts = ["crossbar", kind, hostPart];
50
+ if (portPart) parts.push(portPart);
51
+ return parts.join("-");
52
+ }
53
+
54
+ function defaultPortForProtocol(protocol: string): string {
55
+ if (protocol === "https:") return "443";
56
+ if (protocol === "http:") return "80";
57
+ return "";
58
+ }
59
+
60
+ /**
61
+ * Derive the env-var name for the api-key associated with a server id.
62
+ * The env var name is the id uppercased with hyphens replaced by underscores,
63
+ * so `crossbar-ollama-127-0-0-1-11434` → `CROSSBAR_OLLAMA_127_0_0_1_11434`.
64
+ * This is the name used for the `$ENV` handoff in Pi's registerProvider apiKey field.
65
+ */
66
+ export function envVarFor(id: string): string {
67
+ return id.toUpperCase().replace(/-/g, "_");
68
+ }