@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
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();
|