@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/RESEARCH.md ADDED
@@ -0,0 +1,343 @@
1
+ # Crossbar — Phase 0 Research
2
+
3
+ **Status:** Draft for sign-off · **Date:** 2026-06-21 · **Author:** Crossbar orchestrator (Hypabolic)
4
+
5
+ Crossbar is a Pi coding-agent extension: the local/self-hosted inference connector Pi should
6
+ have shipped with. This document records the *verified* facts that the `BackendAdapter` contract
7
+ (Phase 1) will be built on. **No interface or adapter code is written until this is signed off.**
8
+
9
+ Two evidence tiers are used:
10
+
11
+ - **[PI]** — read from the real `earendil-works/pi` (`badlogic/pi-mono`) source at commit `d93b92b`,
12
+ cloned to `.pi-reference/` (gitignored). Cited as `file:line`. Treat as authoritative.
13
+ - **[WEB]** — backend HTTP APIs gathered from official docs/source, June 2026. Endpoints drift;
14
+ every adapter must confirm against a live instance. Cited by URL in the backend tables below.
15
+
16
+ ---
17
+
18
+ ## 1. Executive summary — what makes Crossbar buildable (and how it wins)
19
+
20
+ 1. **Pi already exposes a first-class provider API to extensions.** `pi.registerProvider(name, config)`
21
+ takes a `baseUrl`, `apiKey`, an `api` type, and a list of models with full capability metadata.
22
+ The built-in `api: "openai-completions"` covers the *entire* OpenAI-compatible tail
23
+ (Ollama, LM Studio, vLLM, llama.cpp, TabbyAPI, KoboldCpp, oobabooga, Jan, llamafile);
24
+ `anthropic-messages` covers Anthropic. **Crossbar writes zero streaming code** for these. [PI]
25
+ 2. **The hard, unsolved problems — the ones every existing connector punts on — are *discovery*,
26
+ *loaded-model introspection*, *model switching*, and *in-TUI onboarding*.** Pi does none of these.
27
+ That is exactly where Crossbar's `BackendAdapter` earns its keep.
28
+ 3. **`/login` is a builtin and cannot be hooked.** Crossbar's onboarding therefore lives in its own
29
+ command (proposed `/crossbar`), while every backend Crossbar registers *also* shows up in the
30
+ stock `/login` selector automatically. (Design decision — see §4 and the sign-off questions.) [PI]
31
+ 4. **No backend advertises over mDNS/zeroconf.** Discovery = active localhost port-probe + response
32
+ fingerprinting. A clean, fast probe matrix is a core deliverable. [WEB]
33
+ 5. **Prior art is uniformly single-backend.** The closest, `v2nic/pi-ollama-provider`, is Ollama-only
34
+ with no cross-backend switch. Crossbar's multi-backend probe + capability-driven hot-swap is novel
35
+ in the Pi ecosystem. [WEB]
36
+
37
+ ---
38
+
39
+ ## 2. Pi integration surface (authoritative — read from source) [PI]
40
+
41
+ ### 2.1 Extension lifecycle
42
+
43
+ - Entry point: `export default function (pi: ExtensionAPI): void | Promise<void>`
44
+ — `core/extensions/types.ts:1416` (`ExtensionFactory`). Async factories are awaited before
45
+ `session_start` flushes, so async auto-discovery at startup is supported. (`docs/extensions.md:182`)
46
+ - **Do not** start long-lived resources in the factory (it may run with no session). Defer to
47
+ `session_start`; clean up in `session_shutdown`. (`docs/extensions.md:219`)
48
+ - Auto-loaded from `~/.pi/agent/extensions/*.ts` and `.pi/extensions/*.ts`, or via a package listed
49
+ in `settings.json` `packages`. (`docs/extensions.md:108`)
50
+ - Relevant registration APIs on `pi` (`core/extensions/types.ts:1120-1385`):
51
+ - `registerCommand(name, { description?, getArgumentCompletions?, handler })`
52
+ - `registerShortcut(keyId, { description?, handler })`
53
+ - `registerTool(toolDef)` · `registerFlag(name, opts)` · `getFlag(name)`
54
+ - `registerProvider(name, config)` · `unregisterProvider(name)` ← **core to Crossbar**
55
+ - `setModel(model)` · `getThinkingLevel()` / `setThinkingLevel()`
56
+ - `events` (shared `EventBus` for cross-extension comms)
57
+ - Relevant events (`pi.on(...)`): `session_start` (`reason: startup|reload|new|resume|fork`),
58
+ `session_shutdown`, `model_select`, `before_agent_start`, `project_trust`, `input`, `tool_call`,
59
+ `tool_result`. (`docs/extensions.md:274-879`)
60
+
61
+ ### 2.2 Provider & model registration — the central contract
62
+
63
+ `pi.registerProvider(name, config)` where `config` is `ProviderConfigInput`
64
+ (`core/model-registry.ts:968-992`, `docs/custom-provider.md`):
65
+
66
+ ```ts
67
+ interface ProviderConfigInput {
68
+ name?: string; // display name (appears in /login + /model)
69
+ baseUrl?: string;
70
+ apiKey?: string; // "$ENV" | "${A}_${B}" | "!shell-cmd" | literal | "$$lit" | "$!lit"
71
+ api?: Api; // "openai-completions" | "anthropic-messages" | "openai-responses" | ...
72
+ headers?: Record<string,string>;
73
+ authHeader?: boolean; // adds Authorization: Bearer
74
+ streamSimple?: (model, context, opts?) => AssistantMessageEventStream; // only for novel APIs
75
+ oauth?: Omit<OAuthProviderInterface, "id">;
76
+ models?: Array<{
77
+ id: string; name: string;
78
+ api?: Api; baseUrl?: string; // per-model override of provider defaults
79
+ reasoning: boolean; // supports extended thinking
80
+ thinkingLevelMap?: { minimal?: string|null; low?: ...; medium?: ...; high?: ...; xhigh?: ... };
81
+ input: ("text" | "image")[];
82
+ cost: { input: number; output: number; cacheRead: number; cacheWrite: number }; // 0s for local
83
+ contextWindow: number; // tokens
84
+ maxTokens: number; // max output tokens
85
+ headers?: Record<string,string>;
86
+ compat?: OpenAICompletionsCompat; // per-backend quirk flags (see below)
87
+ }>;
88
+ }
89
+ ```
90
+
91
+ - Known API types: `openai-completions`, `openai-responses`, `anthropic-messages`,
92
+ `mistral-conversations`, `google-generative-ai`, `google-vertex`, `bedrock-converse-stream`,
93
+ `azure-openai-responses`, `openai-codex-responses` (`packages/ai/src/types.ts:6-15`).
94
+ - `OpenAICompletionsCompat` (`packages/ai/src/types.ts:396-443`) carries the quirk flags that let one
95
+ API type serve many backends: `maxTokensField: "max_completion_tokens"|"max_tokens"`,
96
+ `supportsDeveloperRole`, `supportsReasoningEffort`, `supportsUsageInStreaming`,
97
+ `thinkingFormat: "openai"|"openrouter"|"deepseek"|"qwen"|"qwen-chat-template"|"chat-template"|...`,
98
+ `requiresToolResultName`, `cacheControlFormat`, etc. **Per-backend tuning of these flags is part of
99
+ each adapter's job.**
100
+ - The agent loop surfaces registered models via the `ModelRegistry`
101
+ (`getAll()`, `getAvailable()`, `hasConfiguredAuth()`, `find()` — `core/model-registry.ts:352-373`),
102
+ the `/model` command (re-reads on open), and `pi --list-models` (`cli/list-models.ts`).
103
+ - `docs/custom-provider.md` documents the exact dynamic-discovery pattern Crossbar uses: async factory
104
+ → `fetch(baseUrl + "/v1/models")` → `registerProvider(...)` with discovered models.
105
+ - **Implication for the contract:** Pi-registration is *shared plumbing* — a thin map from a discovered
106
+ server + model list onto `registerProvider`. The differentiating verbs (`introspectLoaded`,
107
+ `switchModel`, `loadUnload`, `health`) are backend-specific HTTP and belong on the adapter.
108
+
109
+ ### 2.3 Credential & config persistence
110
+
111
+ - **Credentials:** `~/.pi/agent/auth.json`, file mode `0600`, dir `0700`, enforced on every write;
112
+ file-locked against concurrent instances (`core/auth-storage.ts:49-160`; path `config.ts:520`).
113
+ Public API via `ctx.modelRegistry.authStorage`: `set(provider, cred)`, `get`, `remove`, `list`,
114
+ `getApiKey(provider)`, `getAuthStatus`. Credential shapes:
115
+ `{ type: "api_key", key, env? }` or `{ type: "oauth", access, refresh, expires, ... }`
116
+ (`core/auth-storage.ts:24-36`).
117
+ - **No per-extension settings namespace.** `appendEntry` is **session-scoped only** — unsuitable for a
118
+ durable multi-server registry (`docs/extensions.md:1702`).
119
+ - **Crossbar's persistence model (proposed):**
120
+ - Non-secret server metadata (url, kind, label, last-known models, enabled) →
121
+ a dedicated `~/.pi/agent/crossbar.json`, located via the exported `getAgentDir()`.
122
+ - Secrets → `auth.json` via `authStorage.set(providerId, { type: "api_key", key })`, keyed by a
123
+ Crossbar-generated stable provider id. This stores keys exactly the way Pi does, with `0600` perms.
124
+ - Key resolution order: CLI `--api-key` → `auth.json` → env var (`packages/ai/src/env-api-keys.ts`) →
125
+ custom fallback (`core/auth-storage.ts:464-534`).
126
+
127
+ ### 2.4 TUI primitives & theme
128
+
129
+ - Onboarding overlay: `ctx.ui.custom<T>(factory, { overlay: true, overlayOptions })` returns a Promise
130
+ resolved by a `done(result)` callback (`core/extensions/types.ts:124-275`). `overlayOptions`:
131
+ `width`/`maxHeight` (number or `"60%"`), `anchor`, `offsetX/Y`, `margin` (`packages/tui/src/tui.ts:171`).
132
+ - Components: `SelectList(items: SelectItem[], maxVisible, theme, layout?)` with
133
+ `SelectItem { value, label, description? }`, `onSelect`/`onCancel`/`onSelectionChange`, `setFilter`
134
+ (`packages/tui/src/components/select-list.ts:40-138`); plus `Container`, `Text`, `Spacer`, `Box`,
135
+ `DynamicBorder` (`packages/tui/src/tui.ts`, `.../components/dynamic-border.ts`).
136
+ - Custom component contract: object with `render(width): string[]`, `invalidate()`, `handleInput(data)`;
137
+ call `tui.requestRender()` after state changes. Key matching via `matchesKey(data, Key.up|down|enter|escape)`
138
+ (`packages/tui/src/keys.ts`).
139
+ - Simpler prompts without a custom component: `ctx.ui.select(title, string[])`,
140
+ `ctx.ui.confirm(title, msg)`, `ctx.ui.input(title, placeholder?)`, `ctx.ui.notify(msg, type)`.
141
+ - **Live "currently loaded" widget:** `ctx.ui.setStatus(key, text)` (footer) and
142
+ `ctx.ui.setWidget(key, lines|factory, { placement: "aboveEditor"|"belowEditor" })`. Update from the
143
+ `model_select` event and from Crossbar's health/introspection poll. Pattern proven in
144
+ `examples/extensions/model-status.ts:12-31` and `status-line.ts:14-32`.
145
+ - **Theme tokens only — never raw ANSI.** `theme.fg(token, text)`, `theme.bg(token, text)`,
146
+ `theme.bold/italic/underline`. Tokens include `text accent muted dim success error warning border
147
+ borderAccent selectedBg ...` (`modes/interactive/theme/theme.ts:107-160`). Re-apply styles per line;
148
+ rebuild styled strings in `invalidate()` so theme switches take effect.
149
+
150
+ ### 2.5 Trust / security model
151
+
152
+ - Extensions run **unsandboxed** with the user's full permissions; project-trust gates *loading*, not
153
+ execution (`docs/security.md`). Reaching `localhost:*` needs no special permission → probing is fine.
154
+ - **No automatic key redaction anywhere.** Crossbar must never log API keys, never echo them in
155
+ notifications, and never write them outside `auth.json`. (Hard rule.)
156
+
157
+ ### 2.6 Packaging
158
+
159
+ - Package manifest declares `"pi": { "extensions": ["./..."] }`; install via
160
+ `pi install npm:@hypabolic/crossbar@<ver>` / `git:` / local path (`docs/packages.md`).
161
+ - **Deps must be exact-pinned** — `scripts/check-pinned-deps.mjs` rejects `^`/`~`.
162
+ - Peer deps: `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui`, `typebox`.
163
+ - **`@hypabolic` npm scope is empty/available** (`@hypabolic/crossbar` → 404); unscoped `crossbar`
164
+ is taken → publish as **`@hypabolic/crossbar`**. (Verified against npm registry 2026-06-21.)
165
+
166
+ ---
167
+
168
+ ## 3. Backend HTTP APIs [WEB — verify live per adapter]
169
+
170
+ Ports, list/introspect/switch endpoints, auth, and a discovery fingerprint per backend. Full source
171
+ URLs are in the per-stream notes; the most load-bearing facts:
172
+
173
+ ### 3.1 Ollama — `:11434`
174
+ - List `GET /api/tags` + OpenAI `GET /v1/models`. Loaded: **`GET /api/ps`** (`expires_at`, `size_vram`).
175
+ - Caps: `POST /api/show` → `capabilities[]` (`completion vision tools embedding thinking`) +
176
+ `model_info.<arch>.context_length`.
177
+ - Switch: **implicit** — request a different model id; it loads on demand. Unload: `keep_alive: 0`.
178
+ - Health `GET /` → `"Ollama is running"`. Version `GET /api/version`. Auth: none local.
179
+ - **Fingerprint:** `GET /` body `Ollama is running`.
180
+
181
+ ### 3.2 LM Studio — `:1234`
182
+ - OpenAI `/v1/*` **plus** richer `GET /api/v0/models` → per-model `state: "loaded"|"not-loaded"`,
183
+ `type: llm|vlm|embeddings`, `max_context_length`, `loaded_context_length`, `quantization`, `arch`.
184
+ (v0.4.0+ also has `/api/v1/*`.)
185
+ - Switch/load: JIT on request; explicit `POST /api/v1/models/load` / `/unload`; `lms` CLI. TTL auto-evict.
186
+ - No dedicated `/health` or `/version` (infer from `/api/v0/models` 200). Auth: Bearer, none by default.
187
+ - **Fingerprint:** `GET /api/v0/models` 200 with `state`/`compatibility_type`.
188
+
189
+ ### 3.3 llama.cpp `llama-server` — `:8080` (single model per instance)
190
+ - `GET /v1/models` (+ non-standard `meta.n_ctx_train`), `GET /props`
191
+ (`default_generation_settings.n_ctx`, `model_path`, `build_info`, `modalities`), `GET /slots`,
192
+ `GET /health` → `{"status":"ok"}`. Auth: none / `--api-key` (Bearer; `/health`+`/v1/models` stay public).
193
+ - **No hot-swap** in classic deployments (model fixed at launch with `-m`). (master adds a router mode.)
194
+ - **Fingerprint:** `GET /props` with `default_generation_settings` + `build_info`.
195
+
196
+ ### 3.4 llama-swap — `:8080` (proxy; enables switching for llama.cpp et al.)
197
+ - Repo **`mostlygeek/llama-swap`**, default `:8080` (**not** 8081). OpenAI + Anthropic compatible front door.
198
+ - `GET /v1/models` aggregates all YAML-configured models; **`GET /running`**, `GET /upstream/{model}`,
199
+ `POST /api/models/unload[/{id}]`, `GET /api/events` (SSE), `GET /health` → `OK`, dashboard at `/ui`.
200
+ - Switch: request a `model` id → starts that upstream, **stops the current one** (single-at-a-time by
201
+ default), proxies. Idle unload via `ttl`/`globalTTL`. Auth: optional `apiKeys` (Basic/Bearer/x-api-key).
202
+ - **Fingerprint:** `/` redirects to `/ui/`; llama-swap-only paths `/running`, `/upstream/{model}`;
203
+ multiple models on `:8080`.
204
+
205
+ ### 3.5 vLLM — `:8000` (single served model)
206
+ - `GET /v1/models` → `ModelCard { id, max_model_len, owned_by:"vllm", root, parent }`.
207
+ `GET /health`, `GET /version` → `{"version":...}` (both unauth even when keyed).
208
+ - **No base-model hot-swap.** Runtime: Sleep/Wake (`/sleep`,`/wake_up`,`/is_sleeping`, dev mode) and
209
+ dynamic LoRA (`/v1/load_lora_adapter`, `/unload_lora_adapter`). Auth: `--api-key` (Bearer; guards `/v1` only).
210
+ - **Fingerprint:** `GET /version` + `/metrics` with `vllm:`-prefixed metrics + `owned_by:"vllm"`.
211
+
212
+ ### 3.6 OpenAI cloud — `https://api.openai.com/v1`
213
+ - `GET /v1/models` (id/object/created/owned_by — **no per-model caps**). Bearer auth. No `/health`.
214
+ Crossbar must carry its own static capability table for known OpenAI model families.
215
+
216
+ ### 3.7 Anthropic cloud — `https://api.anthropic.com/v1`
217
+ - `GET /v1/models` **exists** → `{ id, display_name, capabilities{image_input,pdf_input,thinking,...},
218
+ max_input_tokens, max_tokens }` (verify live: doc sample showed `0` token fields).
219
+ Headers: `x-api-key` + `anthropic-version: 2023-06-01`. Use built-in `api: "anthropic-messages"`.
220
+
221
+ ### 3.8 Generic OpenAI-compatible tail
222
+ | Backend | Port | Load/unload API | Auth | Fingerprint |
223
+ |---|---|---|---|---|
224
+ | TabbyAPI | 5000 | `POST /v1/model/{load,unload}` | `x-api-key` + **`x-admin-key`** | `/v1/model/*` family + `x-admin-key` |
225
+ | KoboldCpp | 5001 | none (single GGUF) | `--password` Bearer | `GET /api/extra/version` → `{"result":"KoboldCpp"}` |
226
+ | oobabooga | 5000 | `POST /v1/internal/model/{load,unload}` | `--api-key` Bearer | `/v1/internal/*` namespace |
227
+ | Jan | 1337 | engine-managed | Bearer | weak; log line only |
228
+ | llamafile | 8080 | none | `--api-key` | `/props` like llama-server; non-`bNNNN` build_info |
229
+
230
+ One **generic OpenAI-compatible adapter** (fingerprint anything serving `/v1/models`) covers this tail;
231
+ TabbyAPI/oobabooga get optional load/unload upgrades when their admin paths are detected.
232
+
233
+ ---
234
+
235
+ ## 4. The `/login` constraint → onboarding design
236
+
237
+ `/login` is a builtin and exposes **no extension hook** (`core/slash-commands.ts:34`;
238
+ `modes/interactive/.../login-dialog.ts`). Two facts shape the design:
239
+
240
+ 1. Any provider Crossbar registers (with `apiKey`/`oauth`) **appears in the stock `/login` selector
241
+ automatically** — so cloud keys (OpenAI/Anthropic) entered via `/login` "just work" for Crossbar's
242
+ registered models.
243
+ 2. The rich part of the mission — auto-discovered local servers, no-auth toggle, test-connection,
244
+ model pick, live switching — **cannot be injected into `/login`** and must be Crossbar's own command.
245
+
246
+ **Proposed:** a `/crossbar` command (alias `/local`) that opens the discovery/onboarding overlay, plus
247
+ auto-discovery on `session_start` that registers found servers (which then also surface in `/login` and
248
+ `/model`). This honours the mission's intent ("configuration happens inside Pi's TUI, zero hand-edited
249
+ JSON") within Pi's actual extension surface. **Confirm at sign-off.**
250
+
251
+ ---
252
+
253
+ ## 5. Prior art & how Crossbar surpasses it [WEB]
254
+
255
+ | Extension | Scope | Stops at |
256
+ |---|---|---|
257
+ | `v2nic/pi-ollama-provider` (closest) | Ollama only | no LM Studio/llama.cpp/vLLM; no cross-backend switch; no presets |
258
+ | `CaptCanadaMan/pi-ollama` | Ollama only | native chat fix only; no switch UI |
259
+ | `woolst/pi-ollama-capabilities` | Ollama only | **manual** capability maps, no live introspection |
260
+ | `fgrehm/pi-ollama-cloud` | Ollama cloud only | no local backends |
261
+ | `aliou/pi-synthetic` | one cloud API | models hardcoded, no discovery |
262
+
263
+ Every one is single-backend or hardcoded. **None probes multiple local engines in one connector, none
264
+ does cross-backend runtime switching, and the only "loadout" tool needs hand-authored capability maps.**
265
+ Crossbar's multi-backend probe + live introspection + capability-driven switch is new in the Pi
266
+ ecosystem. (Multi-backend probing exists only in non-Pi apps.)
267
+
268
+ ---
269
+
270
+ ## 6. Draft `BackendAdapter` contract (sketch — to be locked in Phase 1, NOT yet code)
271
+
272
+ Shown so sign-off can react to the shape. Names/signatures are provisional.
273
+
274
+ ```ts
275
+ enum Capability {
276
+ ListModels, IntrospectLoaded, SwitchModel, LoadUnload, Health, PerModelCaps, Streaming,
277
+ }
278
+ type AuthMode = "none" | "apiKey";
279
+
280
+ interface DiscoveredServer { kind: BackendKind; baseUrl: string; auth: AuthMode; version?: string; }
281
+ interface ModelDescriptor { // maps onto registerProvider's model[] entry
282
+ id: string; name: string;
283
+ contextWindow?: number; maxTokens?: number;
284
+ input: ("text"|"image")[]; reasoning?: boolean;
285
+ tools?: boolean; embeddings?: boolean;
286
+ compat?: OpenAICompletionsCompat;
287
+ }
288
+ interface LoadedState { loadedModelIds: string[]; perModel?: Record<string, {vram?: number; expiresAt?: number}>; }
289
+
290
+ interface BackendAdapter {
291
+ readonly kind: BackendKind;
292
+ readonly capabilities: ReadonlySet<Capability>;
293
+ readonly piApi: "openai-completions" | "anthropic-messages"; // which built-in to register under
294
+
295
+ fingerprint(baseUrl: string, probe: Probe): Promise<DiscoveredServer | null>; // discovery
296
+ health(server: DiscoveredServer): Promise<HealthStatus>;
297
+ listModels(server, auth): Promise<ModelDescriptor[]>;
298
+ introspectLoaded?(server, auth): Promise<LoadedState>; // gated by IntrospectLoaded
299
+ switchModel?(server, auth, modelId): Promise<void>; // gated by SwitchModel
300
+ loadUnload?(server, auth, modelId, action): Promise<void>; // gated by LoadUnload
301
+ }
302
+ ```
303
+
304
+ Cross-cutting (orchestrator-owned, not per-adapter): the discovery engine, the server registry +
305
+ persistence, the provider-registration shim onto `pi.registerProvider`, the onboarding overlay, the
306
+ loaded-model widget, and capability-driven rendering (hide "switch" when unsupported, show last-known
307
+ loaded model when introspection is absent).
308
+
309
+ ---
310
+
311
+ ## 7. Decisions (signed off 2026-06-21)
312
+
313
+ 1. **Onboarding entry point** — ✅ `/crossbar` command (alias `/local`) + `session_start`
314
+ auto-discovery. Registered backends also appear in stock `/login`/`/model`. (§4)
315
+ 2. **v1 backend scope** — ✅ full set: first-class adapters for Ollama, LM Studio, llama.cpp,
316
+ llama-swap, vLLM, OpenAI, Anthropic **+** one generic OpenAI-compat adapter (covers TabbyAPI/
317
+ KoboldCpp/oobabooga/Jan/llamafile; the first three get optional load/unload upgrades when their
318
+ admin paths are fingerprinted).
319
+ 3. **Discovery default** — ✅ localhost-only by default; LAN host-range probing is explicit opt-in
320
+ (no mDNS exists for any backend; unsolicited LAN scanning is intrusive).
321
+ 4. **Secret storage** — ✅ Pi-native split: secrets in `auth.json` via `authStorage` (`0600`, keyed by
322
+ a Crossbar-generated stable provider id); non-secret server metadata in `~/.pi/agent/crossbar.json`
323
+ via `getAgentDir()`. (§2.3)
324
+
325
+ ---
326
+
327
+ ## 8. Risks & unverified items
328
+
329
+ - **[WEB] all backend endpoints** need live confirmation per adapter; HTTP `Server:` headers were not
330
+ captured from live instances — prefer JSON-shape/unique-path fingerprints over headers.
331
+ - **Ollama** exact context key `<arch>.context_length` inferred from convention — verify on `/api/show`.
332
+ - **LM Studio** `loaded_context_length` and lack of `/health`/`/version` inferred — verify.
333
+ - **llama-server router/multi-model** mode is evolving on master (build-date dependent).
334
+ - **Anthropic `/v1/models`** token fields showed `0` in the doc sample — confirm real values live.
335
+ - **OpenAI** exposes no per-model caps → Crossbar needs a maintained static table for OpenAI families.
336
+ - **[PI] `registerProvider` in the published npm build:** verified in source at `d93b92b`; confirm the
337
+ exact signature exists in the pinned published version Crossbar will depend on before locking Phase 1.
338
+ - Prior-art limitations taken from READMEs, not source; `awesome-pi-agent` list is archived (entries
339
+ recovered from git history) — treat the prior-art survey as directional.
340
+
341
+ ---
342
+
343
+ *Companion file:* `CAPABILITY-MATRIX.md` (backend × capability, with endpoints).
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "name": "@hypabolic/crossbar",
3
+ "version": "0.1.0",
4
+ "description": "The local/self-hosted inference connector Pi should have shipped with — multi-backend discovery, model switching, and zero-JSON in-TUI onboarding for the Pi coding agent.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Hypabolic",
8
+ "homepage": "https://github.com/hypabolic/crossbar",
9
+ "keywords": [
10
+ "pi-package",
11
+ "pi-extension",
12
+ "pi-coding-agent",
13
+ "llm",
14
+ "local-llm",
15
+ "ollama",
16
+ "lm-studio",
17
+ "llama.cpp",
18
+ "llama-swap",
19
+ "vllm",
20
+ "openai-compatible"
21
+ ],
22
+ "pi": {
23
+ "extensions": [
24
+ "./src/index.ts"
25
+ ]
26
+ },
27
+ "files": [
28
+ "src",
29
+ "RESEARCH.md",
30
+ "CAPABILITY-MATRIX.md",
31
+ "ARCHITECTURE.md",
32
+ "README.md",
33
+ "LICENSE"
34
+ ],
35
+ "scripts": {
36
+ "check": "tsc --noEmit",
37
+ "test": "vitest run",
38
+ "test:watch": "vitest"
39
+ },
40
+ "peerDependencies": {
41
+ "@earendil-works/pi-coding-agent": "0.79.9",
42
+ "@earendil-works/pi-tui": "0.79.9",
43
+ "typebox": "1.2.19"
44
+ },
45
+ "devDependencies": {
46
+ "@earendil-works/pi-coding-agent": "0.79.9",
47
+ "@earendil-works/pi-tui": "0.79.9",
48
+ "@types/node": "26.0.0",
49
+ "typebox": "1.2.19",
50
+ "typescript": "6.0.3",
51
+ "vitest": "4.1.9"
52
+ }
53
+ }
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Anthropic cloud backend adapter for Crossbar.
3
+ *
4
+ * Anthropic is a CLOUD backend: configured by the user, never port-probed. Hence:
5
+ * - `fingerprint(...)` ALWAYS returns null (cloud adapters are not discovered).
6
+ * - capabilities: ListModels + PerModelCaps + Streaming.
7
+ *
8
+ * AUTH: unlike OpenAI, Anthropic does NOT use a Bearer token. It requires
9
+ * - `x-api-key: <key>`
10
+ * - `anthropic-version: 2023-06-01`
11
+ * We set these explicitly on the probe `init.headers` from `cred.apiKey`. The key
12
+ * is only ever placed into the request header — it is NEVER logged or serialized.
13
+ *
14
+ * listModels: GET /v1/models → data[] carries per-model caps
15
+ * ({ id, display_name, max_input_tokens, max_tokens, capabilities{...} }), so
16
+ * we read them directly and only fall back to a static table when missing.
17
+ *
18
+ * Uses ONLY the injected Probe — never calls fetch directly.
19
+ */
20
+
21
+ import { Capability } from "../core/capability.ts";
22
+ import type { BackendAdapter, PiApiType } from "../core/backend-adapter.ts";
23
+ import type {
24
+ DiscoveredServer,
25
+ ModelDescriptor,
26
+ PiModelEntry,
27
+ Probe,
28
+ ServerCredential,
29
+ } from "../core/types.ts";
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // API response shape
33
+ // ---------------------------------------------------------------------------
34
+
35
+ interface AnthropicModelRow {
36
+ id: string;
37
+ display_name?: string;
38
+ max_input_tokens?: number;
39
+ max_tokens?: number;
40
+ capabilities?: {
41
+ image_input?: boolean;
42
+ thinking?: boolean;
43
+ [k: string]: unknown;
44
+ };
45
+ }
46
+
47
+ interface AnthropicModelsResponse {
48
+ data?: AnthropicModelRow[];
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Constants & static fallback table
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const DEFAULT_BASE_URL = "https://api.anthropic.com";
56
+ const ANTHROPIC_VERSION = "2023-06-01";
57
+
58
+ const DEFAULT_CONTEXT_WINDOW = 200_000;
59
+ const DEFAULT_MAX_TOKENS = 8192;
60
+
61
+ /** Static fallback caps keyed by an id-prefix matcher, used when the API row
62
+ * omits a field (e.g. max_input_tokens 0/missing). First match wins. */
63
+ interface StaticRule {
64
+ match: (id: string) => boolean;
65
+ contextWindow: number;
66
+ maxTokens: number;
67
+ }
68
+
69
+ const STATIC_TABLE: StaticRule[] = [
70
+ { match: (id) => id.startsWith("claude-opus-4"), contextWindow: 200_000, maxTokens: 32_000 },
71
+ { match: (id) => id.startsWith("claude-sonnet-4"), contextWindow: 200_000, maxTokens: 64_000 },
72
+ { match: (id) => id.startsWith("claude-3-7-sonnet"), contextWindow: 200_000, maxTokens: 64_000 },
73
+ { match: (id) => id.startsWith("claude-3-5-sonnet"), contextWindow: 200_000, maxTokens: 8192 },
74
+ { match: (id) => id.startsWith("claude-3-5-haiku"), contextWindow: 200_000, maxTokens: 8192 },
75
+ { match: (id) => id.startsWith("claude-3-opus"), contextWindow: 200_000, maxTokens: 4096 },
76
+ { match: (id) => id.startsWith("claude-3-haiku"), contextWindow: 200_000, maxTokens: 4096 },
77
+ ];
78
+
79
+ function staticFallback(id: string): { contextWindow: number; maxTokens: number } {
80
+ const lower = id.toLowerCase();
81
+ for (const rule of STATIC_TABLE) {
82
+ if (rule.match(lower)) return { contextWindow: rule.contextWindow, maxTokens: rule.maxTokens };
83
+ }
84
+ return { contextWindow: DEFAULT_CONTEXT_WINDOW, maxTokens: DEFAULT_MAX_TOKENS };
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // AnthropicAdapter
89
+ // ---------------------------------------------------------------------------
90
+
91
+ class AnthropicAdapter implements BackendAdapter {
92
+ readonly kind = "anthropic" as const;
93
+ readonly displayName = "Anthropic";
94
+ readonly defaultPorts: readonly number[] = [];
95
+ readonly piApi: PiApiType = "anthropic-messages";
96
+ readonly capabilities: ReadonlySet<Capability> = new Set<Capability>([
97
+ Capability.ListModels,
98
+ Capability.PerModelCaps,
99
+ Capability.Streaming,
100
+ ]);
101
+
102
+ // --- fingerprint ----------------------------------------------------------
103
+
104
+ /** Cloud backend: configured, never probed. Always null. */
105
+ async fingerprint(_baseUrl: string, _probe: Probe): Promise<DiscoveredServer | null> {
106
+ return null;
107
+ }
108
+
109
+ // --- listModels -----------------------------------------------------------
110
+
111
+ async listModels(
112
+ _server: DiscoveredServer,
113
+ cred: ServerCredential,
114
+ probe: Probe,
115
+ ): Promise<ModelDescriptor[]> {
116
+ const r = await probe("/v1/models", {
117
+ method: "GET",
118
+ headers: this._authHeaders(cred),
119
+ });
120
+ if (!r.ok) {
121
+ if (r.status === 401) throw new Error("401 Unauthorized");
122
+ if (r.status === 0) throw new Error("server unreachable (status:0)");
123
+ throw new Error(`listModels failed: status ${r.status}`);
124
+ }
125
+
126
+ const body = r.json as AnthropicModelsResponse | undefined;
127
+ const rows = body?.data ?? [];
128
+
129
+ return rows
130
+ .filter((m) => typeof m.id === "string" && m.id.length > 0)
131
+ .map((m) => this._describe(m));
132
+ }
133
+
134
+ /**
135
+ * Build Anthropic auth headers from the credential. The API key is placed only
136
+ * into the `x-api-key` header — never logged or serialized anywhere.
137
+ */
138
+ private _authHeaders(cred: ServerCredential): Record<string, string> {
139
+ const headers: Record<string, string> = { "anthropic-version": ANTHROPIC_VERSION };
140
+ if (cred.mode === "apiKey" && cred.apiKey) {
141
+ headers["x-api-key"] = cred.apiKey;
142
+ }
143
+ return headers;
144
+ }
145
+
146
+ /** Map one API row → ModelDescriptor, reading caps with static fallback. */
147
+ private _describe(m: AnthropicModelRow): ModelDescriptor {
148
+ const fallback = staticFallback(m.id);
149
+ const contextWindow =
150
+ typeof m.max_input_tokens === "number" && m.max_input_tokens > 0
151
+ ? m.max_input_tokens
152
+ : fallback.contextWindow;
153
+ const maxTokens =
154
+ typeof m.max_tokens === "number" && m.max_tokens > 0 ? m.max_tokens : fallback.maxTokens;
155
+ const hasImage = m.capabilities?.image_input === true;
156
+ const hasThinking = m.capabilities?.thinking === true;
157
+
158
+ return {
159
+ id: m.id,
160
+ name: m.display_name && m.display_name.length > 0 ? m.display_name : m.id,
161
+ contextWindow,
162
+ maxTokens,
163
+ input: hasImage ? ["text", "image"] : ["text"],
164
+ reasoning: hasThinking,
165
+ tools: true,
166
+ embeddings: false,
167
+ raw: m,
168
+ };
169
+ }
170
+
171
+ // --- toPiModel ------------------------------------------------------------
172
+
173
+ toPiModel(_server: DiscoveredServer, model: ModelDescriptor): PiModelEntry {
174
+ return {
175
+ id: model.id,
176
+ name: model.name,
177
+ reasoning: model.reasoning ?? false,
178
+ input: model.input.length > 0 ? model.input : ["text"],
179
+ // Crossbar does not bill — costs are always zero.
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 || DEFAULT_BASE_URL;
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Singleton export
195
+ // ---------------------------------------------------------------------------
196
+
197
+ export const anthropicAdapter: BackendAdapter = new AnthropicAdapter();