@hypabolic/crossbar 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +168 -0
- package/CAPABILITY-MATRIX.md +49 -0
- package/LICENSE +21 -0
- package/README.md +127 -0
- package/RESEARCH.md +343 -0
- package/package.json +53 -0
- package/src/adapters/anthropic.ts +197 -0
- package/src/adapters/generic.ts +164 -0
- package/src/adapters/index.ts +64 -0
- package/src/adapters/llamacpp.ts +217 -0
- package/src/adapters/llamaswap.ts +276 -0
- package/src/adapters/lmstudio.ts +307 -0
- package/src/adapters/ollama.ts +340 -0
- package/src/adapters/openai.ts +195 -0
- package/src/adapters/vllm.ts +197 -0
- package/src/core/backend-adapter.ts +123 -0
- package/src/core/capability.ts +53 -0
- package/src/core/index.ts +36 -0
- package/src/core/types.ts +160 -0
- package/src/discovery/engine.ts +247 -0
- package/src/discovery/probe.ts +144 -0
- package/src/index.ts +158 -0
- package/src/registry/ids.ts +68 -0
- package/src/registry/persistence.ts +111 -0
- package/src/registry/pi-credential-store.ts +27 -0
- package/src/registry/registry.ts +150 -0
- package/src/shim/provider-shim.ts +187 -0
- package/src/ui/loaded-widget.ts +220 -0
- package/src/ui/onboarding.ts +439 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossbar config persistence — crossbar.json (non-secret) and the CredentialStore boundary.
|
|
3
|
+
*
|
|
4
|
+
* crossbar.json lives at getAgentDir()/crossbar.json and contains only non-secret server metadata.
|
|
5
|
+
* API keys are stored separately via the CredentialStore (backed by Pi's authStorage at runtime).
|
|
6
|
+
*
|
|
7
|
+
* The `dir` override in opts lets tests pass a temp directory instead of touching ~/.pi.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
14
|
+
import type { CrossbarConfigFile, ServerRecord } from "../core/types.ts";
|
|
15
|
+
|
|
16
|
+
export { tmpdir }; // re-export for tests
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Interface that Pi's authStorage will be adapted to at runtime.
|
|
20
|
+
* Kept as a plain interface here so the persistence module never imports Pi runtime.
|
|
21
|
+
*/
|
|
22
|
+
export interface CredentialStore {
|
|
23
|
+
get(id: string): string | undefined | Promise<string | undefined>;
|
|
24
|
+
set(id: string, key: string): void | Promise<void>;
|
|
25
|
+
remove(id: string): void | Promise<void>;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface PersistenceOpts {
|
|
29
|
+
/** Override the directory for crossbar.json (default: getAgentDir()). Used in tests. */
|
|
30
|
+
dir?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function resolveDir(opts?: PersistenceOpts): string {
|
|
34
|
+
return opts?.dir ?? getAgentDir();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function configPath(opts?: PersistenceOpts): string {
|
|
38
|
+
return join(resolveDir(opts), "crossbar.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Strip any `apiKey` field that may have leaked into a ServerRecord.
|
|
43
|
+
* This is a safety guard: crossbar.json must never contain secrets.
|
|
44
|
+
*/
|
|
45
|
+
function stripSecrets(record: ServerRecord): ServerRecord {
|
|
46
|
+
const { ...safe } = record as ServerRecord & { apiKey?: unknown };
|
|
47
|
+
// biome-ignore lint: intentional runtime secret guard
|
|
48
|
+
delete (safe as Record<string, unknown>)["apiKey"];
|
|
49
|
+
return safe;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Validate that a parsed value looks like a CrossbarConfigFile.
|
|
54
|
+
* Returns the canonical form, or null if the value is invalid.
|
|
55
|
+
*/
|
|
56
|
+
function parseConfigFile(raw: unknown): CrossbarConfigFile | null {
|
|
57
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
58
|
+
const obj = raw as Record<string, unknown>;
|
|
59
|
+
if (obj["version"] !== 1) return null;
|
|
60
|
+
if (!Array.isArray(obj["servers"])) return null;
|
|
61
|
+
const result: CrossbarConfigFile = {
|
|
62
|
+
version: 1,
|
|
63
|
+
servers: (obj["servers"] as unknown[]).filter(
|
|
64
|
+
(s): s is ServerRecord => typeof s === "object" && s !== null,
|
|
65
|
+
),
|
|
66
|
+
};
|
|
67
|
+
const rawSettings = obj["settings"];
|
|
68
|
+
if (typeof rawSettings === "object" && rawSettings !== null) {
|
|
69
|
+
result.settings = rawSettings as NonNullable<CrossbarConfigFile["settings"]>;
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const EMPTY_CONFIG: CrossbarConfigFile = { version: 1, servers: [] };
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load crossbar.json from the agent dir (or the override dir in tests).
|
|
78
|
+
* Returns a default empty config if the file is missing or invalid.
|
|
79
|
+
*/
|
|
80
|
+
export async function loadConfig(opts?: PersistenceOpts): Promise<CrossbarConfigFile> {
|
|
81
|
+
const path = configPath(opts);
|
|
82
|
+
try {
|
|
83
|
+
const text = readFileSync(path, "utf-8");
|
|
84
|
+
const parsed = JSON.parse(text) as unknown;
|
|
85
|
+
const config = parseConfigFile(parsed);
|
|
86
|
+
return config ?? { ...EMPTY_CONFIG };
|
|
87
|
+
} catch {
|
|
88
|
+
return { ...EMPTY_CONFIG };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Atomically write crossbar.json (temp file + rename).
|
|
94
|
+
* Strips any apiKey fields that may have crept into ServerRecords.
|
|
95
|
+
*/
|
|
96
|
+
export async function saveConfig(config: CrossbarConfigFile, opts?: PersistenceOpts): Promise<void> {
|
|
97
|
+
const dir = resolveDir(opts);
|
|
98
|
+
mkdirSync(dir, { recursive: true });
|
|
99
|
+
|
|
100
|
+
const safe: CrossbarConfigFile = {
|
|
101
|
+
...config,
|
|
102
|
+
servers: config.servers.map(stripSecrets),
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const json = JSON.stringify(safe, null, 2);
|
|
106
|
+
const dest = configPath(opts);
|
|
107
|
+
// Write to a sibling temp file in the same dir so rename is atomic (same filesystem)
|
|
108
|
+
const tmp = join(dir, `.crossbar-${Date.now()}-${Math.random().toString(36).slice(2)}.tmp`);
|
|
109
|
+
writeFileSync(tmp, json, { encoding: "utf-8" });
|
|
110
|
+
renameSync(tmp, dest);
|
|
111
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bridges Pi's `AuthStorage` (reached at runtime via `ctx.modelRegistry.authStorage`) to Crossbar's
|
|
3
|
+
* framework-free {@link CredentialStore} boundary. This is the ONLY place keys cross into Pi's store —
|
|
4
|
+
* they land in `auth.json` (mode 0600) keyed by the Crossbar provider id, exactly like Pi's own creds.
|
|
5
|
+
*
|
|
6
|
+
* Keeping the adapter here (not in persistence.ts) preserves the rule that the persistence/registry
|
|
7
|
+
* core never imports Pi runtime, so they stay unit-testable with a fake store.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { AuthStorage } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import type { CredentialStore } from "./persistence.ts";
|
|
12
|
+
|
|
13
|
+
/** Wrap a Pi `AuthStorage` as a Crossbar `CredentialStore` (api-key credentials only). */
|
|
14
|
+
export function createPiCredentialStore(authStorage: AuthStorage): CredentialStore {
|
|
15
|
+
return {
|
|
16
|
+
get(id: string): string | undefined {
|
|
17
|
+
const cred = authStorage.get(id);
|
|
18
|
+
return cred?.type === "api_key" ? cred.key : undefined;
|
|
19
|
+
},
|
|
20
|
+
set(id: string, key: string): void {
|
|
21
|
+
authStorage.set(id, { type: "api_key", key });
|
|
22
|
+
},
|
|
23
|
+
remove(id: string): void {
|
|
24
|
+
authStorage.remove(id);
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory ServerRegistry — CRUD, health cache, and credential resolution.
|
|
3
|
+
*
|
|
4
|
+
* The registry is the single source of truth for server state at runtime.
|
|
5
|
+
* It is constructed with injected dependencies (CredentialStore, persist fn, clock) so it
|
|
6
|
+
* is fully testable without touching the filesystem or Pi runtime.
|
|
7
|
+
*
|
|
8
|
+
* Session wiring (session_start poll, session_shutdown cleanup) is Wave C and is NOT here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type { CrossbarConfigFile, ServerCredential, ServerRecord } from "../core/types.ts";
|
|
12
|
+
import type { CredentialStore } from "./persistence.ts";
|
|
13
|
+
|
|
14
|
+
export interface RegistryDeps {
|
|
15
|
+
store: CredentialStore;
|
|
16
|
+
/** Write the current state to disk. The registry calls this after every mutation. */
|
|
17
|
+
persist: (config: CrossbarConfigFile) => Promise<void>;
|
|
18
|
+
/** Clock injection for testability. Defaults to Date.now. */
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface HealthCachePatch {
|
|
23
|
+
models?: ServerRecord["lastKnownModels"];
|
|
24
|
+
loaded?: ServerRecord["lastKnownLoaded"];
|
|
25
|
+
lastSeenAt?: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ServerRegistry {
|
|
29
|
+
private readonly records: Map<string, ServerRecord> = new Map();
|
|
30
|
+
private readonly store: CredentialStore;
|
|
31
|
+
private readonly persist: (config: CrossbarConfigFile) => Promise<void>;
|
|
32
|
+
private readonly now: () => number;
|
|
33
|
+
|
|
34
|
+
constructor(deps: RegistryDeps) {
|
|
35
|
+
this.store = deps.store;
|
|
36
|
+
this.persist = deps.persist;
|
|
37
|
+
this.now = deps.now ?? (() => Date.now());
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
// Initialisation — load records from a previously-read config file
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
/** Populate the in-memory registry from a loaded CrossbarConfigFile. */
|
|
45
|
+
load(config: CrossbarConfigFile): void {
|
|
46
|
+
this.records.clear();
|
|
47
|
+
for (const record of config.servers) {
|
|
48
|
+
this.records.set(record.id, record);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Read
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
list(): ServerRecord[] {
|
|
57
|
+
return Array.from(this.records.values());
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get(id: string): ServerRecord | undefined {
|
|
61
|
+
return this.records.get(id);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// Write
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Add (or replace) a server record and optionally store its API key.
|
|
70
|
+
* Persists after a successful write.
|
|
71
|
+
*/
|
|
72
|
+
async add(record: ServerRecord, apiKey?: string): Promise<void> {
|
|
73
|
+
this.records.set(record.id, record);
|
|
74
|
+
if (apiKey !== undefined) {
|
|
75
|
+
await this.store.set(record.id, apiKey);
|
|
76
|
+
}
|
|
77
|
+
await this.flush();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Apply a partial patch to an existing record.
|
|
82
|
+
* Throws if the id is not found.
|
|
83
|
+
*/
|
|
84
|
+
async update(id: string, patch: Partial<Omit<ServerRecord, "id">>): Promise<void> {
|
|
85
|
+
const existing = this.records.get(id);
|
|
86
|
+
if (!existing) throw new Error(`ServerRegistry: unknown id "${id}"`);
|
|
87
|
+
this.records.set(id, { ...existing, ...patch });
|
|
88
|
+
await this.flush();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Remove a server record and its stored credential.
|
|
93
|
+
* No-op (no throw) if the id is not found.
|
|
94
|
+
*/
|
|
95
|
+
async remove(id: string): Promise<void> {
|
|
96
|
+
if (!this.records.has(id)) return;
|
|
97
|
+
this.records.delete(id);
|
|
98
|
+
await this.store.remove(id);
|
|
99
|
+
await this.flush();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Enable or disable a server without touching any other fields. */
|
|
103
|
+
async setEnabled(id: string, enabled: boolean): Promise<void> {
|
|
104
|
+
await this.update(id, { enabled });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---------------------------------------------------------------------------
|
|
108
|
+
// Health / model cache (non-persisting fast-path — called from the poll loop)
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Update the cached health snapshot for a server.
|
|
113
|
+
* Does NOT persist — health cache is ephemeral, reconstructed on next poll.
|
|
114
|
+
*/
|
|
115
|
+
updateHealthCache(id: string, patch: HealthCachePatch): void {
|
|
116
|
+
const existing = this.records.get(id);
|
|
117
|
+
if (!existing) return;
|
|
118
|
+
const updated: ServerRecord = { ...existing };
|
|
119
|
+
if (patch.models !== undefined) updated.lastKnownModels = patch.models;
|
|
120
|
+
if (patch.loaded !== undefined) updated.lastKnownLoaded = patch.loaded;
|
|
121
|
+
if (patch.lastSeenAt !== undefined) updated.lastSeenAt = patch.lastSeenAt;
|
|
122
|
+
this.records.set(id, updated);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Credential resolution
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Resolve the runtime credential for a server record.
|
|
131
|
+
* - mode "none" → { mode: "none" }
|
|
132
|
+
* - mode "apiKey" → fetch the key from the store; returns { mode: "apiKey", apiKey }
|
|
133
|
+
* (apiKey may be undefined if the key has not been stored yet)
|
|
134
|
+
*/
|
|
135
|
+
async resolveCredential(record: ServerRecord): Promise<ServerCredential> {
|
|
136
|
+
if (record.auth === "none") {
|
|
137
|
+
return { mode: "none" };
|
|
138
|
+
}
|
|
139
|
+
const apiKey = await this.store.get(record.id);
|
|
140
|
+
return { mode: "apiKey", ...(apiKey !== undefined ? { apiKey } : {}) };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Internal
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
private async flush(): Promise<void> {
|
|
148
|
+
await this.persist({ version: 1, servers: this.list() });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-registration shim — the ONLY place Crossbar calls Pi's
|
|
3
|
+
* `pi.registerProvider` / `pi.unregisterProvider`.
|
|
4
|
+
*
|
|
5
|
+
* # API-key handoff decision (verified against Pi source at commit d93b92b)
|
|
6
|
+
*
|
|
7
|
+
* Pi's `validateProviderConfig` (.pi-reference/packages/coding-agent/src/core/model-registry.ts:882)
|
|
8
|
+
* throws when `models` is provided but `apiKey` is absent AND `oauth` is absent:
|
|
9
|
+
*
|
|
10
|
+
* if (!config.apiKey && !config.oauth) {
|
|
11
|
+
* throw new Error(`Provider ${providerName}: "apiKey" or "oauth" is required when defining models.`);
|
|
12
|
+
* }
|
|
13
|
+
*
|
|
14
|
+
* Pi's `getApiKeyAndHeaders` (model-registry.ts:703–716) resolves the key in this order:
|
|
15
|
+
* 1. `authStorage.getApiKey(providerName)` — reads from auth.json under the provider id
|
|
16
|
+
* 2. Fallback: `resolveConfigValue(providerConfig.apiKey)` — resolves the $ENV expression
|
|
17
|
+
*
|
|
18
|
+
* Crossbar calls `authStorage.set(record.id, { type: "api_key", key: <literal> })` BEFORE
|
|
19
|
+
* calling `registerProvider`, so Pi's step-1 always finds the key in auth.json and the
|
|
20
|
+
* `$ENV` expression is NEVER evaluated against environment variables.
|
|
21
|
+
*
|
|
22
|
+
* Therefore the correct approach is:
|
|
23
|
+
* - Pass `apiKey: "$" + envVarFor(record.id)` when `hasApiKey === true`.
|
|
24
|
+
* This satisfies Pi's validation without ever inlining a plaintext key into the
|
|
25
|
+
* ProviderConfig (which Pi stores in memory as `registeredProviders`).
|
|
26
|
+
* - Omit `apiKey` entirely when the server requires no auth — Pi's validation
|
|
27
|
+
* only fires when `models` is provided, and it passes when `apiKey` is absent
|
|
28
|
+
* only if `oauth` is present; BUT local no-auth servers must still pass the
|
|
29
|
+
* validation. We therefore supply `apiKey: ""` — wait, that is falsy and will
|
|
30
|
+
* also trigger the throw.
|
|
31
|
+
*
|
|
32
|
+
* Re-reading model-registry.ts:882: the check is `!config.apiKey`, which is truthy
|
|
33
|
+
* for the empty string `""`. So for no-auth servers we need a non-empty sentinel.
|
|
34
|
+
* The Pi docs (custom-provider.md) show `apiKey: "$LOCAL_OPENAI_API_KEY"` even for
|
|
35
|
+
* local servers where the env var is unset — Pi treats an unresolved env var as
|
|
36
|
+
* "no key" and does not inject an Authorization header in that case (the header is
|
|
37
|
+
* only added when `authStorage.getApiKey` returns a non-undefined value OR when
|
|
38
|
+
* `authHeader: true` is set).
|
|
39
|
+
*
|
|
40
|
+
* FINAL DECISION:
|
|
41
|
+
* - `auth === "apiKey"`: pass `apiKey: "$" + envVarFor(record.id)`.
|
|
42
|
+
* Pi reads the literal key from auth.json (step 1 above). Safe — no plaintext.
|
|
43
|
+
* - `auth === "none"`: pass `apiKey: "$" + envVarFor(record.id)` but omit the
|
|
44
|
+
* env var from the environment (and Crossbar never sets it). Pi will resolve
|
|
45
|
+
* the env var as undefined and skip the Authorization header.
|
|
46
|
+
* This satisfies the validation AND causes no harm for no-auth backends.
|
|
47
|
+
*
|
|
48
|
+
* Evidence:
|
|
49
|
+
* - Validation: model-registry.ts:882
|
|
50
|
+
* - Resolution: model-registry.ts:703–716
|
|
51
|
+
* - resolveConfigValue env handling: resolve-config-value.ts:101–113
|
|
52
|
+
* (returns undefined when env var is absent → getApiKeyAndHeaders returns undefined apiKey)
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
import type { ProviderConfig, ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
56
|
+
import type { ServerRecord, DiscoveredServer, ModelDescriptor } from "../core/types.ts";
|
|
57
|
+
import { adapterFor } from "../adapters/index.ts";
|
|
58
|
+
import { envVarFor } from "../registry/ids.ts";
|
|
59
|
+
import type { ServerRegistry } from "../registry/registry.ts";
|
|
60
|
+
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// buildProviderConfig — pure mapping, no I/O
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build the ProviderConfig that should be handed to `pi.registerProvider(record.id, config)`.
|
|
67
|
+
*
|
|
68
|
+
* PURE — no I/O, no side effects. Only call this after the model cache is populated.
|
|
69
|
+
*
|
|
70
|
+
* @param record - Persistent server record (id, kind, label, auth, …).
|
|
71
|
+
* @param server - Discovered-server snapshot (baseUrl, auth, …).
|
|
72
|
+
* @param models - Full model list as returned by `adapter.listModels`; embedding models
|
|
73
|
+
* are filtered out here and never registered with Pi.
|
|
74
|
+
* @param opts.hasApiKey - True when the registry has a key stored in auth.json for this
|
|
75
|
+
* server. Controls whether the `$ENV` sentinel is injected.
|
|
76
|
+
*/
|
|
77
|
+
export function buildProviderConfig(
|
|
78
|
+
record: ServerRecord,
|
|
79
|
+
server: DiscoveredServer,
|
|
80
|
+
models: ModelDescriptor[],
|
|
81
|
+
opts?: { hasApiKey?: boolean },
|
|
82
|
+
): ProviderConfig {
|
|
83
|
+
const adapter = adapterFor(record.kind);
|
|
84
|
+
|
|
85
|
+
// Filter out embedding-only models — Pi registers these as chat models which
|
|
86
|
+
// is incorrect. Non-embedding models are the ones Pi cares about.
|
|
87
|
+
const chatModels = models.filter((m) => !m.embeddings);
|
|
88
|
+
|
|
89
|
+
// Map each chat model through the adapter's owned toPiModel logic.
|
|
90
|
+
const piModels: ProviderConfig["models"] = chatModels.map((m) =>
|
|
91
|
+
adapter.toPiModel(server, m),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
// API-key sentinel: always include so Pi's validation passes.
|
|
95
|
+
// For no-auth servers the env var will be absent → Pi resolves it as
|
|
96
|
+
// undefined and omits the Authorization header. For keyed servers Pi reads
|
|
97
|
+
// the literal from auth.json first (getApiKeyAndHeaders step 1).
|
|
98
|
+
// NEVER pass a plaintext key here.
|
|
99
|
+
const apiKey = "$" + envVarFor(record.id);
|
|
100
|
+
|
|
101
|
+
const config: ProviderConfig = {
|
|
102
|
+
name: record.label,
|
|
103
|
+
baseUrl: adapter.inferenceBaseUrl(server),
|
|
104
|
+
api: adapter.piApi,
|
|
105
|
+
apiKey,
|
|
106
|
+
models: piModels,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
// When there is no key stored, remove apiKey only if the server uses no auth.
|
|
110
|
+
// For auth === "apiKey" servers, hasApiKey should always be true by the time we
|
|
111
|
+
// call this; but guard defensively: if somehow we're asked to register a keyed
|
|
112
|
+
// server with no stored key, still emit the sentinel (Pi will just fail auth).
|
|
113
|
+
if (record.auth === "none" && opts?.hasApiKey !== true) {
|
|
114
|
+
// For no-auth local backends the env var is unset at runtime, so Pi will
|
|
115
|
+
// resolve apiKey as undefined — effectively no Authorization header.
|
|
116
|
+
// We keep the sentinel to satisfy Pi's schema validation.
|
|
117
|
+
// (No change needed — apiKey is already the sentinel.)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
return config;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// registerServer
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Resolve the server's credential, build its ProviderConfig, and register it
|
|
129
|
+
* with Pi. Idempotent — Pi's `registerProvider` replaces an existing registration.
|
|
130
|
+
*/
|
|
131
|
+
export async function registerServer(
|
|
132
|
+
pi: ExtensionAPI,
|
|
133
|
+
registry: ServerRegistry,
|
|
134
|
+
record: ServerRecord,
|
|
135
|
+
models: ModelDescriptor[],
|
|
136
|
+
): Promise<void> {
|
|
137
|
+
const cred = await registry.resolveCredential(record);
|
|
138
|
+
const hasApiKey = cred.mode === "apiKey" && cred.apiKey !== undefined;
|
|
139
|
+
|
|
140
|
+
// Retrieve the DiscoveredServer shape from the record.
|
|
141
|
+
// The registry stores the canonicalised baseUrl; reconstruct a minimal server
|
|
142
|
+
// descriptor for the adapter calls (only baseUrl and kind are needed here).
|
|
143
|
+
const server: DiscoveredServer = {
|
|
144
|
+
kind: record.kind,
|
|
145
|
+
baseUrl: record.baseUrl,
|
|
146
|
+
auth: record.auth,
|
|
147
|
+
label: record.label,
|
|
148
|
+
confidence: 1,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const config = buildProviderConfig(record, server, models, { hasApiKey });
|
|
152
|
+
pi.registerProvider(record.id, config);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// unregisterServer
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Remove this server's Pi provider registration.
|
|
161
|
+
* Pi restores any built-in models that were overridden.
|
|
162
|
+
*/
|
|
163
|
+
export function unregisterServer(pi: ExtensionAPI, record: ServerRecord): void {
|
|
164
|
+
pi.unregisterProvider(record.id);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
// reRegisterServer
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Re-register a server after its model list has changed.
|
|
173
|
+
*
|
|
174
|
+
* Pi's `registerProvider` with `models` replaces the existing model list, so
|
|
175
|
+
* a plain call to `registerServer` would suffice — but the ARCHITECTURE.md
|
|
176
|
+
* contract specifies explicit unregister-then-register to guarantee a clean
|
|
177
|
+
* replacement (removes stale models, resets compat flags, etc.).
|
|
178
|
+
*/
|
|
179
|
+
export async function reRegisterServer(
|
|
180
|
+
pi: ExtensionAPI,
|
|
181
|
+
registry: ServerRegistry,
|
|
182
|
+
record: ServerRecord,
|
|
183
|
+
models: ModelDescriptor[],
|
|
184
|
+
): Promise<void> {
|
|
185
|
+
unregisterServer(pi, record);
|
|
186
|
+
await registerServer(pi, registry, record, models);
|
|
187
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live "currently loaded" model widget for Crossbar.
|
|
3
|
+
*
|
|
4
|
+
* Two public surfaces:
|
|
5
|
+
*
|
|
6
|
+
* 1. PURE, UNIT-TESTABLE functions:
|
|
7
|
+
* - `formatLoadedStatus` — renders the status string from pre-computed entries.
|
|
8
|
+
* - `computeLoadedEntries` — fetches live loaded state from each enabled server,
|
|
9
|
+
* falling back to lastKnownLoaded when introspection is unavailable.
|
|
10
|
+
*
|
|
11
|
+
* 2. `installLoadedWidget` — wires everything to Pi's `ctx.ui.setStatus`, subscribes
|
|
12
|
+
* to `model_select`, and exposes a `refresh()` the health-poll loop calls.
|
|
13
|
+
*
|
|
14
|
+
* Hard rules:
|
|
15
|
+
* - Never modify src/core/, src/adapters/, src/registry/.
|
|
16
|
+
* - Only the injected Probe (createProbe) is used for network calls.
|
|
17
|
+
* - API keys are NEVER logged.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
21
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
22
|
+
|
|
23
|
+
import { adapterFor } from "../adapters/index.ts";
|
|
24
|
+
import { canIntrospect } from "../core/backend-adapter.ts";
|
|
25
|
+
import { createProbe } from "../discovery/probe.ts";
|
|
26
|
+
import type { ServerRegistry } from "../registry/registry.ts";
|
|
27
|
+
import type { LoadedState } from "../core/types.ts";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Types
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** One entry per enabled server, ready for formatting. */
|
|
34
|
+
export interface LoadedEntry {
|
|
35
|
+
/** Human label for the server, e.g. "Ollama". */
|
|
36
|
+
label: string;
|
|
37
|
+
/** Currently-loaded model ids (may be empty). */
|
|
38
|
+
loaded: string[];
|
|
39
|
+
/** Whether the data came from a live introspection call or a cache. */
|
|
40
|
+
source: LoadedState["source"];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Pure formatter — no I/O, fully unit-testable
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Render a compact status string from pre-computed loaded entries.
|
|
49
|
+
*
|
|
50
|
+
* Examples:
|
|
51
|
+
* live: "● Ollama:llama3.1"
|
|
52
|
+
* last-known "◷ vLLM:qwen (last-known)"
|
|
53
|
+
* empty set "no servers"
|
|
54
|
+
* multi "● Ollama:llama3.1 ◷ vLLM:qwen (last-known)"
|
|
55
|
+
*
|
|
56
|
+
* Uses only `theme.fg(token, text)` with tokens: accent/success/dim/muted/warning.
|
|
57
|
+
*/
|
|
58
|
+
export function formatLoadedStatus(
|
|
59
|
+
entries: LoadedEntry[],
|
|
60
|
+
theme: Pick<Theme, "fg">,
|
|
61
|
+
): string {
|
|
62
|
+
if (entries.length === 0) {
|
|
63
|
+
return theme.fg("muted", "no servers");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parts: string[] = [];
|
|
67
|
+
|
|
68
|
+
for (const entry of entries) {
|
|
69
|
+
const isLive = entry.source === "introspection";
|
|
70
|
+
|
|
71
|
+
if (entry.loaded.length === 0) {
|
|
72
|
+
// Server known but no model loaded
|
|
73
|
+
const marker = isLive
|
|
74
|
+
? theme.fg("dim", "○")
|
|
75
|
+
: theme.fg("warning", "◷");
|
|
76
|
+
const label = theme.fg("dim", `${entry.label}:idle`);
|
|
77
|
+
const suffix = isLive ? "" : theme.fg("dim", " (last-known)");
|
|
78
|
+
parts.push(`${marker} ${label}${suffix}`);
|
|
79
|
+
} else {
|
|
80
|
+
for (const modelId of entry.loaded) {
|
|
81
|
+
const marker = isLive
|
|
82
|
+
? theme.fg("accent", "●")
|
|
83
|
+
: theme.fg("warning", "◷");
|
|
84
|
+
const text = isLive
|
|
85
|
+
? theme.fg("success", `${entry.label}:${modelId}`)
|
|
86
|
+
: theme.fg("muted", `${entry.label}:${modelId}`);
|
|
87
|
+
const suffix = isLive ? "" : theme.fg("dim", " (last-known)");
|
|
88
|
+
parts.push(`${marker} ${text}${suffix}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return parts.join(" ");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
// Entry computation — one network round-trip per server that supports introspection
|
|
98
|
+
// ---------------------------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* For each enabled server in the registry:
|
|
102
|
+
* - If its adapter supports `IntrospectLoaded`, call `introspectLoaded` via a fresh
|
|
103
|
+
* Probe → source "introspection".
|
|
104
|
+
* - Otherwise fall back to `record.lastKnownLoaded` → source "last-known".
|
|
105
|
+
* - Per-server failures degrade that entry to "last-known" (never throw the whole batch).
|
|
106
|
+
*/
|
|
107
|
+
export async function computeLoadedEntries(
|
|
108
|
+
registry: ServerRegistry,
|
|
109
|
+
): Promise<LoadedEntry[]> {
|
|
110
|
+
const records = registry.list().filter((r) => r.enabled);
|
|
111
|
+
const results = await Promise.allSettled(
|
|
112
|
+
records.map(async (record): Promise<LoadedEntry> => {
|
|
113
|
+
const adapter = adapterFor(record.kind);
|
|
114
|
+
|
|
115
|
+
if (canIntrospect(adapter)) {
|
|
116
|
+
// Resolve credential for the probe
|
|
117
|
+
const cred = await registry.resolveCredential(record);
|
|
118
|
+
|
|
119
|
+
// Build a minimal DiscoveredServer from the stored record
|
|
120
|
+
const server = {
|
|
121
|
+
kind: record.kind,
|
|
122
|
+
baseUrl: record.baseUrl,
|
|
123
|
+
auth: record.auth,
|
|
124
|
+
label: record.label,
|
|
125
|
+
confidence: 1,
|
|
126
|
+
} as const;
|
|
127
|
+
|
|
128
|
+
const probe = createProbe(record.baseUrl, { auth: cred });
|
|
129
|
+
|
|
130
|
+
const state = await adapter.introspectLoaded(server, cred, probe);
|
|
131
|
+
return {
|
|
132
|
+
label: record.label,
|
|
133
|
+
loaded: state.loadedModelIds,
|
|
134
|
+
source: "introspection" as const,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Fall back to last-known
|
|
139
|
+
return {
|
|
140
|
+
label: record.label,
|
|
141
|
+
loaded: record.lastKnownLoaded ?? [],
|
|
142
|
+
source: "last-known" as const,
|
|
143
|
+
};
|
|
144
|
+
}),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
const entries: LoadedEntry[] = [];
|
|
148
|
+
for (let i = 0; i < results.length; i++) {
|
|
149
|
+
const result = results[i];
|
|
150
|
+
if (result === undefined) continue;
|
|
151
|
+
if (result.status === "fulfilled") {
|
|
152
|
+
entries.push(result.value);
|
|
153
|
+
} else {
|
|
154
|
+
// Per-server failure: degrade to last-known without throwing
|
|
155
|
+
const record = records[i];
|
|
156
|
+
if (record !== undefined) {
|
|
157
|
+
entries.push({
|
|
158
|
+
label: record.label,
|
|
159
|
+
loaded: record.lastKnownLoaded ?? [],
|
|
160
|
+
source: "last-known" as const,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return entries;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---------------------------------------------------------------------------
|
|
170
|
+
// Widget installer — thin Pi-API wiring
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/** The status key used with `ctx.ui.setStatus`. */
|
|
174
|
+
const STATUS_KEY = "crossbar-loaded";
|
|
175
|
+
|
|
176
|
+
export interface LoadedWidgetHandle {
|
|
177
|
+
/** Called by the health-poll loop to push a fresh snapshot to the status bar. */
|
|
178
|
+
refresh(): Promise<void>;
|
|
179
|
+
/** Clean up the event subscription. */
|
|
180
|
+
dispose(): void;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Wire the loaded-model widget to Pi's status bar.
|
|
185
|
+
*
|
|
186
|
+
* - Reads `ctx.ui.theme` each refresh so theme switches take effect immediately.
|
|
187
|
+
* - Listens to `model_select` to refresh on user-driven model changes.
|
|
188
|
+
* - Returns `{ refresh, dispose }` for the health-poll loop to drive.
|
|
189
|
+
*/
|
|
190
|
+
export function installLoadedWidget(
|
|
191
|
+
pi: ExtensionAPI,
|
|
192
|
+
ctx: ExtensionContext,
|
|
193
|
+
registry: ServerRegistry,
|
|
194
|
+
): LoadedWidgetHandle {
|
|
195
|
+
async function refresh(): Promise<void> {
|
|
196
|
+
try {
|
|
197
|
+
const entries = await computeLoadedEntries(registry);
|
|
198
|
+
const text = formatLoadedStatus(entries, ctx.ui.theme);
|
|
199
|
+
ctx.ui.setStatus(STATUS_KEY, text);
|
|
200
|
+
} catch {
|
|
201
|
+
// Silently suppress: the widget is best-effort; never crash the poll loop.
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Refresh on every model-select event (user switched model via /model or Ctrl+P).
|
|
206
|
+
// pi.on() returns void — there is no per-listener unsubscribe in the Pi extension API.
|
|
207
|
+
pi.on("model_select", () => {
|
|
208
|
+
void refresh();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// Initial render
|
|
212
|
+
void refresh();
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
refresh,
|
|
216
|
+
dispose(): void {
|
|
217
|
+
ctx.ui.setStatus(STATUS_KEY, undefined);
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}
|