@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,197 @@
1
+ /**
2
+ * vLLM backend adapter for Crossbar.
3
+ *
4
+ * Implements the BackendAdapter contract for vLLM's OpenAI-compatible HTTP API:
5
+ * - Fingerprint: GET /version → {"version":...} AND/OR GET /v1/models with owned_by:"vllm"
6
+ * - List models: GET /v1/models → ModelCard[] {id, max_model_len, owned_by, root}
7
+ * - Health: GET /health (empty 200 ⇒ healthy, 503 ⇒ loading)
8
+ * - Inference base URL: server.baseUrl + "/v1"
9
+ *
10
+ * Capabilities NOT declared (vLLM serves a single fixed model at startup):
11
+ * - IntrospectLoaded — no live introspection endpoint for the base model
12
+ * - SwitchModel — no hot-swap on the base model
13
+ * - LoadUnload — no explicit load/unload on the base model
14
+ *
15
+ * Uses ONLY the injected Probe — never calls fetch directly.
16
+ */
17
+
18
+ import { Capability } from "../core/capability.ts";
19
+ import type { BackendAdapter, PiApiType } from "../core/backend-adapter.ts";
20
+ import type {
21
+ DiscoveredServer,
22
+ HealthStatus,
23
+ ModelDescriptor,
24
+ PiModelEntry,
25
+ Probe,
26
+ ServerCredential,
27
+ } from "../core/types.ts";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Internal shapes matching vLLM's API responses
31
+ // ---------------------------------------------------------------------------
32
+
33
+ interface VllmVersionResponse {
34
+ version?: string;
35
+ }
36
+
37
+ interface VllmModelCard {
38
+ id: string;
39
+ owned_by?: string;
40
+ max_model_len?: number;
41
+ root?: string;
42
+ parent?: string | null;
43
+ }
44
+
45
+ interface VllmModelsResponse {
46
+ data?: VllmModelCard[];
47
+ }
48
+
49
+ // ---------------------------------------------------------------------------
50
+ // Constants
51
+ // ---------------------------------------------------------------------------
52
+
53
+ const DEFAULT_CONTEXT_WINDOW = 8192;
54
+ const DEFAULT_MAX_TOKENS = 4096;
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // VllmAdapter
58
+ // ---------------------------------------------------------------------------
59
+
60
+ class VllmAdapter implements BackendAdapter {
61
+ readonly kind = "vllm" as const;
62
+ readonly displayName = "vLLM";
63
+ readonly defaultPorts: readonly number[] = [8000];
64
+ readonly piApi: PiApiType = "openai-completions";
65
+ readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
66
+ Capability.ListModels,
67
+ Capability.Health,
68
+ Capability.PerModelCaps,
69
+ Capability.Streaming,
70
+ ]);
71
+
72
+ // --- fingerprint ----------------------------------------------------------
73
+
74
+ async fingerprint(baseUrl: string, probe: Probe): Promise<DiscoveredServer | null> {
75
+ // Strategy 1: GET /version → {"version": ...} unique to vLLM
76
+ const versionResult = await probe("/version");
77
+ if (versionResult.status !== 0) {
78
+ if (versionResult.ok) {
79
+ const body = versionResult.json as VllmVersionResponse | undefined;
80
+ if (body && typeof body.version === "string") {
81
+ // /version responded with a version string → high confidence vLLM
82
+ return {
83
+ kind: "vllm",
84
+ baseUrl,
85
+ auth: "none",
86
+ version: body.version,
87
+ label: `vLLM (${baseUrl.replace(/^https?:\/\//, "")})`,
88
+ confidence: 0.9,
89
+ };
90
+ }
91
+ }
92
+ }
93
+
94
+ // Strategy 2: GET /v1/models with owned_by:"vllm" and max_model_len
95
+ const modelsResult = await probe("/v1/models");
96
+ if (modelsResult.status === 0) return null;
97
+ if (!modelsResult.ok) return null;
98
+
99
+ const body = modelsResult.json as VllmModelsResponse | undefined;
100
+ const models = body?.data ?? [];
101
+ const hasVllmModel = models.some(
102
+ (m) => m.owned_by === "vllm" && typeof m.max_model_len === "number",
103
+ );
104
+ if (!hasVllmModel) return null;
105
+
106
+ return {
107
+ kind: "vllm",
108
+ baseUrl,
109
+ auth: "none",
110
+ label: `vLLM (${baseUrl.replace(/^https?:\/\//, "")})`,
111
+ confidence: 0.9,
112
+ };
113
+ }
114
+
115
+ // --- health ---------------------------------------------------------------
116
+
117
+ async health(
118
+ _server: DiscoveredServer,
119
+ _cred: ServerCredential,
120
+ probe: Probe,
121
+ ): Promise<HealthStatus> {
122
+ const r = await probe("/health");
123
+ if (r.status === 0) {
124
+ const s: HealthStatus = { state: "unreachable" };
125
+ if (r.error !== undefined) s.detail = r.error;
126
+ return s;
127
+ }
128
+ if (r.status === 401) return { state: "unauthorized" };
129
+ if (r.status === 503) return { state: "loading" };
130
+ if (!r.ok) return { state: "degraded", detail: `status ${r.status}` };
131
+ const status: HealthStatus = { state: "healthy" };
132
+ if (r.latencyMs !== undefined) status.latencyMs = r.latencyMs;
133
+ return status;
134
+ }
135
+
136
+ // --- listModels -----------------------------------------------------------
137
+
138
+ async listModels(
139
+ _server: DiscoveredServer,
140
+ cred: ServerCredential,
141
+ probe: Probe,
142
+ ): Promise<ModelDescriptor[]> {
143
+ const headers: Record<string, string> = {};
144
+ if (cred.mode === "apiKey" && cred.apiKey) {
145
+ headers["Authorization"] = `Bearer ${cred.apiKey}`;
146
+ }
147
+
148
+ const r = await probe("/v1/models", { method: "GET", headers });
149
+ if (!r.ok) {
150
+ if (r.status === 401) throw new Error("401 Unauthorized");
151
+ if (r.status === 0) throw new Error("server unreachable (status:0)");
152
+ throw new Error(`listModels failed: status ${r.status}`);
153
+ }
154
+
155
+ const body = r.json as VllmModelsResponse | undefined;
156
+ const rawModels = body?.data ?? [];
157
+
158
+ return rawModels.map((m): ModelDescriptor => {
159
+ return {
160
+ id: m.id,
161
+ name: m.id,
162
+ contextWindow: typeof m.max_model_len === "number" ? m.max_model_len : DEFAULT_CONTEXT_WINDOW,
163
+ maxTokens: DEFAULT_MAX_TOKENS,
164
+ input: ["text"],
165
+ reasoning: false,
166
+ embeddings: false,
167
+ raw: m,
168
+ };
169
+ });
170
+ }
171
+
172
+ // --- toPiModel ------------------------------------------------------------
173
+
174
+ toPiModel(_server: DiscoveredServer, model: ModelDescriptor): PiModelEntry {
175
+ return {
176
+ id: model.id,
177
+ name: model.name,
178
+ reasoning: model.reasoning ?? false,
179
+ input: model.input.length > 0 ? model.input : ["text"],
180
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
181
+ contextWindow: model.contextWindow ?? DEFAULT_CONTEXT_WINDOW,
182
+ maxTokens: model.maxTokens ?? DEFAULT_MAX_TOKENS,
183
+ };
184
+ }
185
+
186
+ // --- inferenceBaseUrl -----------------------------------------------------
187
+
188
+ inferenceBaseUrl(server: DiscoveredServer): string {
189
+ return `${server.baseUrl}/v1`;
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Singleton export
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export const vllmAdapter: BackendAdapter = new VllmAdapter();
@@ -0,0 +1,123 @@
1
+ /**
2
+ * The `BackendAdapter` contract — Crossbar's single most important interface and the FROZEN boundary
3
+ * every backend implementation codes against. Phase 2 fan-out (one adapter per backend) targets this
4
+ * file and nothing else; the conformance suite (tests/conformance) validates every adapter against it.
5
+ *
6
+ * Design tenets:
7
+ * - Adapters are STATELESS. All state (selected server, credentials, last-known models) is owned by
8
+ * the registry and passed in. An adapter instance is a singleton describing one backend *kind*.
9
+ * - Adapters perform NO direct I/O. Every network call goes through the injected {@link Probe}, so the
10
+ * same adapter runs unchanged in production, in discovery, and against test fixtures.
11
+ * - Capabilities are HONEST. An optional method is present iff the matching {@link Capability} is in
12
+ * `capabilities`. The orchestrator checks the set, never feature-sniffs the method.
13
+ * - Pi mapping is OWNED by the adapter. `toPiModel` / `inferenceBaseUrl` are the only places that know
14
+ * which built-in Pi API type and compat flags a backend needs; the shim stays generic.
15
+ *
16
+ * See ARCHITECTURE.md for how the registry, discovery engine, and provider shim consume this.
17
+ */
18
+
19
+ import type { Capability, BackendKind } from "./capability.ts";
20
+ import type {
21
+ DiscoveredServer,
22
+ HealthStatus,
23
+ LoadAction,
24
+ LoadedState,
25
+ ModelDescriptor,
26
+ PiModelEntry,
27
+ Probe,
28
+ ServerCredential,
29
+ } from "./types.ts";
30
+
31
+ /** Bumped on any breaking change to this interface. Adapters and the registry assert on it. */
32
+ export const CONTRACT_VERSION = 1 as const;
33
+
34
+ /** Which built-in Pi API type the adapter registers its models under. */
35
+ export type PiApiType = "openai-completions" | "anthropic-messages";
36
+
37
+ export interface BackendAdapter {
38
+ /** Stable identity. One adapter instance per kind. */
39
+ readonly kind: BackendKind;
40
+ /** Human-facing name, e.g. "LM Studio". */
41
+ readonly displayName: string;
42
+ /** Ports the discovery engine probes for this backend (empty for cloud kinds). */
43
+ readonly defaultPorts: readonly number[];
44
+ /** The Pi built-in API type all this backend's models register under. */
45
+ readonly piApi: PiApiType;
46
+ /** The capabilities this backend exposes. Drives UX and which optional methods are present. */
47
+ readonly capabilities: ReadonlySet<Capability>;
48
+
49
+ /**
50
+ * Decide whether `baseUrl` is *this* backend. MUST use only unauthenticated metadata endpoints
51
+ * (most local servers leave `/v1/models`, `/health`, `/props` public even when keyed). Return a
52
+ * {@link DiscoveredServer} with a confidence score, or `null` if this isn't our backend.
53
+ * Cloud adapters return `null` (they are configured, not probed).
54
+ */
55
+ fingerprint(baseUrl: string, probe: Probe): Promise<DiscoveredServer | null>;
56
+
57
+ /** Liveness / readiness. Present iff `capabilities` has {@link Capability.Health}. */
58
+ health?(server: DiscoveredServer, cred: ServerCredential, probe: Probe): Promise<HealthStatus>;
59
+
60
+ /** Enumerate available models. Required (every backend supports {@link Capability.ListModels}). */
61
+ listModels(server: DiscoveredServer, cred: ServerCredential, probe: Probe): Promise<ModelDescriptor[]>;
62
+
63
+ /** Snapshot of currently-loaded models. Present iff {@link Capability.IntrospectLoaded}. */
64
+ introspectLoaded?(
65
+ server: DiscoveredServer,
66
+ cred: ServerCredential,
67
+ probe: Probe,
68
+ ): Promise<LoadedState>;
69
+
70
+ /**
71
+ * Make `modelId` the active/served model. Present iff {@link Capability.SwitchModel}. Implementations
72
+ * range from a no-op + implicit load (Ollama) to a proxy swap that restarts an upstream (llama-swap).
73
+ * MUST reject if the switch cannot be confirmed (server down mid-switch, model not available).
74
+ */
75
+ switchModel?(
76
+ server: DiscoveredServer,
77
+ cred: ServerCredential,
78
+ modelId: string,
79
+ probe: Probe,
80
+ ): Promise<void>;
81
+
82
+ /** Explicit load/unload. Present iff {@link Capability.LoadUnload}. */
83
+ loadUnload?(
84
+ server: DiscoveredServer,
85
+ cred: ServerCredential,
86
+ modelId: string,
87
+ action: LoadAction,
88
+ probe: Probe,
89
+ ): Promise<void>;
90
+
91
+ /**
92
+ * Map one discovered model to the exact entry Pi's `registerProvider` expects. Owns api/compat-flag
93
+ * selection, cost zeros for local backends, and conservative defaults when caps are unknown.
94
+ */
95
+ toPiModel(server: DiscoveredServer, model: ModelDescriptor): PiModelEntry;
96
+
97
+ /**
98
+ * The base URL Pi should use for *inference* against this server. May differ from
99
+ * `server.baseUrl` (e.g. append `/v1` for OpenAI-compat backends, or point at the proxy front door).
100
+ */
101
+ inferenceBaseUrl(server: DiscoveredServer): string;
102
+ }
103
+
104
+ /** Narrowing helpers so the orchestrator never calls an absent optional method. */
105
+ export const supports = (a: BackendAdapter, c: Capability): boolean => a.capabilities.has(c);
106
+
107
+ export function canSwitch(
108
+ a: BackendAdapter,
109
+ ): a is BackendAdapter & Required<Pick<BackendAdapter, "switchModel">> {
110
+ return typeof a.switchModel === "function";
111
+ }
112
+
113
+ export function canIntrospect(
114
+ a: BackendAdapter,
115
+ ): a is BackendAdapter & Required<Pick<BackendAdapter, "introspectLoaded">> {
116
+ return typeof a.introspectLoaded === "function";
117
+ }
118
+
119
+ export function canLoadUnload(
120
+ a: BackendAdapter,
121
+ ): a is BackendAdapter & Required<Pick<BackendAdapter, "loadUnload">> {
122
+ return typeof a.loadUnload === "function";
123
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Crossbar core enums — the capability vocabulary every {@link BackendAdapter} declares against.
3
+ *
4
+ * Locked Phase 1 contract. Adapters MUST NOT add new BackendKind / Capability values without an
5
+ * interface revision (bump CONTRACT_VERSION in ./backend-adapter.ts). See ARCHITECTURE.md.
6
+ */
7
+
8
+ /**
9
+ * The set of capabilities a backend may expose. An adapter declares the subset it supports via
10
+ * {@link BackendAdapter.capabilities}; the UX is driven entirely off this set (hide "switch model"
11
+ * when absent, show last-known instead of live loaded state when introspection is missing, etc.).
12
+ */
13
+ export enum Capability {
14
+ /** Can enumerate available models (`/v1/models`, `/api/tags`, ...). Effectively universal. */
15
+ ListModels = "listModels",
16
+ /** Can report which model(s) are currently resident/loaded right now (`/api/ps`, `state`, `/running`). */
17
+ IntrospectLoaded = "introspectLoaded",
18
+ /** Can change the active/served model at runtime (implicit request, JIT load, or proxy swap). */
19
+ SwitchModel = "switchModel",
20
+ /** Can explicitly load and unload a model (`keep_alive:0`, `/load`+`/unload`, `lms`, ...). */
21
+ LoadUnload = "loadUnload",
22
+ /** Exposes a health/liveness signal (`/health`, `GET /` text, ...). */
23
+ Health = "health",
24
+ /** Exposes per-model capability metadata (context window, vision, tools) beyond bare ids. */
25
+ PerModelCaps = "perModelCaps",
26
+ /** Supports streaming responses. Effectively universal for the chat path. */
27
+ Streaming = "streaming",
28
+ }
29
+
30
+ /** Authentication scheme a server requires. Crossbar only ever sends a bearer/api key or nothing. */
31
+ export type AuthMode = "none" | "apiKey";
32
+
33
+ /**
34
+ * Concrete backend identities Crossbar understands. `openai-generic` is the catch-all fallback for
35
+ * anything that merely exposes `/v1/models` (covers the long tail: Jan, llamafile, unknown servers).
36
+ */
37
+ export type BackendKind =
38
+ | "ollama"
39
+ | "lmstudio"
40
+ | "llamacpp"
41
+ | "llamaswap"
42
+ | "vllm"
43
+ | "openai"
44
+ | "anthropic"
45
+ | "tabbyapi"
46
+ | "koboldcpp"
47
+ | "oobabooga"
48
+ | "jan"
49
+ | "llamafile"
50
+ | "openai-generic";
51
+
52
+ /** Backends that are remote cloud services (configured, never port-probed). */
53
+ export const CLOUD_KINDS: ReadonlySet<BackendKind> = new Set<BackendKind>(["openai", "anthropic"]);
@@ -0,0 +1,36 @@
1
+ /** Crossbar core contract — public surface for adapters, registry, discovery, and the Pi shim. */
2
+
3
+ export {
4
+ Capability,
5
+ CLOUD_KINDS,
6
+ type AuthMode,
7
+ type BackendKind,
8
+ } from "./capability.ts";
9
+
10
+ export {
11
+ CONTRACT_VERSION,
12
+ canIntrospect,
13
+ canLoadUnload,
14
+ canSwitch,
15
+ supports,
16
+ type BackendAdapter,
17
+ type PiApiType,
18
+ } from "./backend-adapter.ts";
19
+
20
+ export type {
21
+ CrossbarConfigFile,
22
+ CrossbarSettings,
23
+ DiscoveredServer,
24
+ HealthState,
25
+ HealthStatus,
26
+ LoadAction,
27
+ LoadedModelInfo,
28
+ LoadedState,
29
+ ModelDescriptor,
30
+ PiModelEntry,
31
+ Probe,
32
+ ProbeInit,
33
+ ProbeResult,
34
+ ServerCredential,
35
+ ServerRecord,
36
+ } from "./types.ts";
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Crossbar core data shapes — the values that flow between discovery, the registry, adapters, and the
3
+ * Pi provider-registration shim. Locked Phase 1 contract. See ARCHITECTURE.md.
4
+ *
5
+ * The single hard tie to Pi is {@link PiModelEntry}: it is derived from Pi's exported `ProviderConfig`
6
+ * so the shim cannot drift from the real `pi.registerProvider` schema.
7
+ */
8
+
9
+ import type { ProviderConfig } from "@earendil-works/pi-coding-agent";
10
+ import type { AuthMode, BackendKind } from "./capability.ts";
11
+
12
+ /** A single model entry exactly as Pi's `registerProvider` expects it. Derived from the real type. */
13
+ export type PiModelEntry = NonNullable<ProviderConfig["models"]>[number];
14
+
15
+ // ---------------------------------------------------------------------------------------------------
16
+ // Probing primitive (injected, not imported) — keeps adapters testable and side-effect free.
17
+ // ---------------------------------------------------------------------------------------------------
18
+
19
+ export interface ProbeInit {
20
+ method?: "GET" | "POST" | "HEAD";
21
+ /** Header map. The orchestrator injects auth headers; adapters add content-type etc. as needed. */
22
+ headers?: Record<string, string>;
23
+ body?: string;
24
+ /** Per-request timeout. Discovery uses a short budget (default supplied by the engine). */
25
+ timeoutMs?: number;
26
+ }
27
+
28
+ export interface ProbeResult {
29
+ /** HTTP status, or 0 when the request never completed (connection refused / timeout). */
30
+ status: number;
31
+ ok: boolean;
32
+ headers: Record<string, string>;
33
+ /** Present when the body was read as text. */
34
+ text?: string;
35
+ /** Present when the body parsed as JSON. Adapters should narrow defensively. */
36
+ json?: unknown;
37
+ /** Round-trip latency in ms, when measured. */
38
+ latencyMs?: number;
39
+ /** Set when the request failed before a response (refused, DNS, timeout). */
40
+ error?: string;
41
+ }
42
+
43
+ /**
44
+ * The injected fetch primitive. `path` is resolved against the server base URL by the caller-provided
45
+ * implementation. Adapters MUST go through this rather than calling `fetch` directly so that timeouts,
46
+ * auth-header injection, redaction, and test fakes all live in one place.
47
+ */
48
+ export type Probe = (path: string, init?: ProbeInit) => Promise<ProbeResult>;
49
+
50
+ // ---------------------------------------------------------------------------------------------------
51
+ // Credentials — secrets are resolved at call time; they are NEVER persisted to crossbar.json and NEVER
52
+ // logged. Persistence of the key itself goes through Pi's authStorage (auth.json, 0600).
53
+ // ---------------------------------------------------------------------------------------------------
54
+
55
+ export interface ServerCredential {
56
+ mode: AuthMode;
57
+ /** Resolved secret, present only when mode === "apiKey". Treat as sensitive; never log or serialize. */
58
+ apiKey?: string;
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------------------------------
62
+ // Discovery & health
63
+ // ---------------------------------------------------------------------------------------------------
64
+
65
+ export interface DiscoveredServer {
66
+ kind: BackendKind;
67
+ /** Normalized origin used for fingerprinting & metadata calls (no trailing slash, no `/v1`). */
68
+ baseUrl: string;
69
+ auth: AuthMode;
70
+ version?: string;
71
+ /** Human label, e.g. "Ollama (127.0.0.1:11434)". */
72
+ label: string;
73
+ /** Fingerprint confidence 0..1; the engine prefers the highest-confidence match per origin. */
74
+ confidence: number;
75
+ }
76
+
77
+ export type HealthState = "healthy" | "loading" | "degraded" | "unauthorized" | "unreachable";
78
+
79
+ export interface HealthStatus {
80
+ state: HealthState;
81
+ detail?: string;
82
+ latencyMs?: number;
83
+ }
84
+
85
+ // ---------------------------------------------------------------------------------------------------
86
+ // Models & loaded state
87
+ // ---------------------------------------------------------------------------------------------------
88
+
89
+ export interface ModelDescriptor {
90
+ id: string;
91
+ name: string;
92
+ /** Context window in tokens, when the backend reports it. */
93
+ contextWindow?: number;
94
+ /** Max output tokens, when known. */
95
+ maxTokens?: number;
96
+ /** Input modalities. Defaults to ["text"] when the backend doesn't say. */
97
+ input: ("text" | "image")[];
98
+ /** Supports extended thinking / reasoning. */
99
+ reasoning?: boolean;
100
+ /** Supports tool/function calling. */
101
+ tools?: boolean;
102
+ /** Is an embeddings model (excluded from chat model registration). */
103
+ embeddings?: boolean;
104
+ /** Best-effort: was this model resident at list time (from introspection)? */
105
+ loaded?: boolean;
106
+ /** Original backend payload, retained for diagnostics. Never registered with Pi. */
107
+ raw?: unknown;
108
+ }
109
+
110
+ export interface LoadedModelInfo {
111
+ vramBytes?: number;
112
+ /** Unix ms when the model will be evicted (Ollama keep_alive, LM Studio TTL). */
113
+ expiresAt?: number;
114
+ /** Runtime context length if it differs from the model's max. */
115
+ contextLength?: number;
116
+ }
117
+
118
+ export interface LoadedState {
119
+ loadedModelIds: string[];
120
+ perModel?: Record<string, LoadedModelInfo>;
121
+ /** How this snapshot was obtained — drives whether the widget shows a live or last-known indicator. */
122
+ source: "introspection" | "last-known" | "unknown";
123
+ }
124
+
125
+ export type LoadAction = "load" | "unload";
126
+
127
+ // ---------------------------------------------------------------------------------------------------
128
+ // Persistence — crossbar.json (non-secret) lives at getAgentDir()/crossbar.json. Secrets are in auth.json.
129
+ // ---------------------------------------------------------------------------------------------------
130
+
131
+ export interface ServerRecord {
132
+ /** Stable Crossbar id. Doubles as the Pi provider name AND the auth.json key for this server. */
133
+ id: string;
134
+ kind: BackendKind;
135
+ /** Normalized origin (same shape as DiscoveredServer.baseUrl). */
136
+ baseUrl: string;
137
+ label: string;
138
+ auth: AuthMode;
139
+ enabled: boolean;
140
+ /** Unix ms. */
141
+ addedAt: number;
142
+ lastSeenAt?: number;
143
+ /** Cached for offline rendering / fast startup; refreshed on health poll. */
144
+ lastKnownModels?: ModelDescriptor[];
145
+ /** Cached loaded-model ids for the "currently loaded" widget when introspection is unavailable. */
146
+ lastKnownLoaded?: string[];
147
+ }
148
+
149
+ export interface CrossbarSettings {
150
+ /** Opt-in LAN host-range probing (default false — localhost only). */
151
+ lanDiscovery?: boolean;
152
+ /** Override the default localhost probe ports. */
153
+ probePorts?: number[];
154
+ }
155
+
156
+ export interface CrossbarConfigFile {
157
+ version: 1;
158
+ servers: ServerRecord[];
159
+ settings?: CrossbarSettings;
160
+ }