bloby-bot 0.46.3 → 0.47.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Pi sub-provider catalog.
3
+ *
4
+ * The Bloby (pi) harness is a meta-provider: the user picks an underlying LLM
5
+ * vendor and supplies their own credentials. This file enumerates the set we
6
+ * currently support in the onboarding wizard plus enough metadata to drive the
7
+ * test-completion call without per-provider branching at the call site.
8
+ *
9
+ * Iteration 1 scope: API-key flows only. OAuth-based sub-providers (Anthropic
10
+ * Pro/Max, GitHub Copilot, OpenAI Codex) are deliberately out of scope — they
11
+ * duplicate auth flows we already ship under the dedicated Claude and OpenAI
12
+ * Codex harnesses.
13
+ */
14
+ export type PiApiFlavor = 'openai-completions' | 'anthropic-messages' | 'google-gemini';
15
+
16
+ export interface PiSubProviderModel {
17
+ id: string;
18
+ label: string;
19
+ }
20
+
21
+ export interface PiSubProvider {
22
+ id: string;
23
+ name: string;
24
+ subtitle: string;
25
+ flavor: PiApiFlavor;
26
+ /** Default base URL — Ollama / LM Studio / custom let the user override it. */
27
+ baseUrl?: string;
28
+ /** Whether the user must supply a base URL (Ollama, LM Studio, custom). */
29
+ needsBaseUrl?: boolean;
30
+ /** Whether the user must supply an API key. Ollama defaults to false. */
31
+ needsApiKey?: boolean;
32
+ /** Optional: where to obtain a key (shown as a help link). */
33
+ apiKeyUrl?: string;
34
+ /** Hand-curated model list. `dynamic` ⇒ free-form ID input. */
35
+ models: PiSubProviderModel[] | 'dynamic';
36
+ /** Default model selection when the user hasn't picked one. */
37
+ defaultModel?: string;
38
+ }
39
+
40
+ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
41
+ {
42
+ id: 'google',
43
+ name: 'Google Gemini',
44
+ subtitle: 'Gemini 2.x via AI Studio',
45
+ flavor: 'google-gemini',
46
+ baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
47
+ needsApiKey: true,
48
+ apiKeyUrl: 'https://aistudio.google.com/apikey',
49
+ models: [
50
+ { id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro' },
51
+ { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' },
52
+ { id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' },
53
+ ],
54
+ defaultModel: 'gemini-2.5-pro',
55
+ },
56
+ {
57
+ id: 'deepseek',
58
+ name: 'DeepSeek',
59
+ subtitle: 'deepseek.com API',
60
+ flavor: 'openai-completions',
61
+ baseUrl: 'https://api.deepseek.com/v1',
62
+ needsApiKey: true,
63
+ apiKeyUrl: 'https://platform.deepseek.com/api_keys',
64
+ models: [
65
+ { id: 'deepseek-chat', label: 'DeepSeek V3 (chat)' },
66
+ { id: 'deepseek-reasoner', label: 'DeepSeek R1 (reasoner)' },
67
+ ],
68
+ defaultModel: 'deepseek-chat',
69
+ },
70
+ {
71
+ id: 'groq',
72
+ name: 'Groq',
73
+ subtitle: 'Fast inference for Llama / Mixtral',
74
+ flavor: 'openai-completions',
75
+ baseUrl: 'https://api.groq.com/openai/v1',
76
+ needsApiKey: true,
77
+ apiKeyUrl: 'https://console.groq.com/keys',
78
+ models: [
79
+ { id: 'llama-3.3-70b-versatile', label: 'Llama 3.3 70B Versatile' },
80
+ { id: 'llama-3.1-8b-instant', label: 'Llama 3.1 8B Instant' },
81
+ { id: 'moonshotai/kimi-k2-instruct', label: 'Kimi K2 Instruct' },
82
+ ],
83
+ defaultModel: 'llama-3.3-70b-versatile',
84
+ },
85
+ {
86
+ id: 'xai',
87
+ name: 'xAI (Grok)',
88
+ subtitle: 'x.ai API',
89
+ flavor: 'openai-completions',
90
+ baseUrl: 'https://api.x.ai/v1',
91
+ needsApiKey: true,
92
+ apiKeyUrl: 'https://console.x.ai/',
93
+ models: [
94
+ { id: 'grok-4', label: 'Grok 4' },
95
+ { id: 'grok-code-fast-1', label: 'Grok Code Fast 1' },
96
+ { id: 'grok-3', label: 'Grok 3' },
97
+ ],
98
+ defaultModel: 'grok-4',
99
+ },
100
+ {
101
+ id: 'cerebras',
102
+ name: 'Cerebras',
103
+ subtitle: 'Wafer-scale inference',
104
+ flavor: 'openai-completions',
105
+ baseUrl: 'https://api.cerebras.ai/v1',
106
+ needsApiKey: true,
107
+ apiKeyUrl: 'https://cloud.cerebras.ai/?tab=api-keys',
108
+ models: [
109
+ { id: 'qwen-3-coder-480b', label: 'Qwen 3 Coder 480B' },
110
+ { id: 'llama-3.3-70b', label: 'Llama 3.3 70B' },
111
+ ],
112
+ defaultModel: 'qwen-3-coder-480b',
113
+ },
114
+ {
115
+ id: 'openrouter',
116
+ name: 'OpenRouter',
117
+ subtitle: 'Aggregator: 300+ models, one key',
118
+ flavor: 'openai-completions',
119
+ baseUrl: 'https://openrouter.ai/api/v1',
120
+ needsApiKey: true,
121
+ apiKeyUrl: 'https://openrouter.ai/keys',
122
+ models: 'dynamic',
123
+ defaultModel: 'anthropic/claude-sonnet-4',
124
+ },
125
+ {
126
+ id: 'mistral',
127
+ name: 'Mistral',
128
+ subtitle: 'mistral.ai API',
129
+ flavor: 'openai-completions',
130
+ baseUrl: 'https://api.mistral.ai/v1',
131
+ needsApiKey: true,
132
+ apiKeyUrl: 'https://console.mistral.ai/api-keys/',
133
+ models: [
134
+ { id: 'mistral-large-latest', label: 'Mistral Large' },
135
+ { id: 'codestral-latest', label: 'Codestral' },
136
+ ],
137
+ defaultModel: 'mistral-large-latest',
138
+ },
139
+ {
140
+ id: 'openai-api',
141
+ name: 'OpenAI (API key)',
142
+ subtitle: 'platform.openai.com',
143
+ flavor: 'openai-completions',
144
+ baseUrl: 'https://api.openai.com/v1',
145
+ needsApiKey: true,
146
+ apiKeyUrl: 'https://platform.openai.com/api-keys',
147
+ models: [
148
+ { id: 'gpt-5', label: 'GPT-5' },
149
+ { id: 'gpt-5-mini', label: 'GPT-5 Mini' },
150
+ { id: 'gpt-4.1', label: 'GPT-4.1' },
151
+ { id: 'o3', label: 'o3' },
152
+ ],
153
+ defaultModel: 'gpt-5',
154
+ },
155
+ {
156
+ id: 'anthropic-api',
157
+ name: 'Anthropic (API key)',
158
+ subtitle: 'console.anthropic.com',
159
+ flavor: 'anthropic-messages',
160
+ baseUrl: 'https://api.anthropic.com/v1',
161
+ needsApiKey: true,
162
+ apiKeyUrl: 'https://console.anthropic.com/settings/keys',
163
+ models: [
164
+ { id: 'claude-opus-4-5', label: 'Claude Opus 4.5' },
165
+ { id: 'claude-sonnet-4-5', label: 'Claude Sonnet 4.5' },
166
+ { id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5' },
167
+ ],
168
+ defaultModel: 'claude-sonnet-4-5',
169
+ },
170
+ {
171
+ id: 'ollama',
172
+ name: 'Ollama',
173
+ subtitle: 'Local — http://localhost:11434',
174
+ flavor: 'openai-completions',
175
+ baseUrl: 'http://localhost:11434/v1',
176
+ needsBaseUrl: true,
177
+ needsApiKey: false,
178
+ apiKeyUrl: 'https://ollama.com/library',
179
+ models: 'dynamic',
180
+ defaultModel: 'llama3.1',
181
+ },
182
+ {
183
+ id: 'lm-studio',
184
+ name: 'LM Studio',
185
+ subtitle: 'Local — http://localhost:1234',
186
+ flavor: 'openai-completions',
187
+ baseUrl: 'http://localhost:1234/v1',
188
+ needsBaseUrl: true,
189
+ needsApiKey: false,
190
+ models: 'dynamic',
191
+ },
192
+ {
193
+ id: 'custom',
194
+ name: 'Custom (OpenAI-compatible)',
195
+ subtitle: 'Any /v1/chat/completions endpoint',
196
+ flavor: 'openai-completions',
197
+ needsBaseUrl: true,
198
+ needsApiKey: true,
199
+ models: 'dynamic',
200
+ },
201
+ ];
202
+
203
+ export function getPiSubProvider(id: string): PiSubProvider | undefined {
204
+ return PI_SUB_PROVIDERS.find((p) => p.id === id);
205
+ }
@@ -0,0 +1,196 @@
1
+ /**
2
+ * Pi test-completion — single-shot, non-streaming completion call.
3
+ *
4
+ * Iteration 1 of the pi harness: just enough to verify the saved sub-provider
5
+ * + credentials actually reach an LLM and return text. Replaces the full
6
+ * pi-ai streaming stack until we vendor it alongside the agent loop.
7
+ *
8
+ * Supported API flavors:
9
+ * - openai-completions → POST {baseUrl}/chat/completions
10
+ * - anthropic-messages → POST {baseUrl}/messages
11
+ * - google-gemini → POST {baseUrl}/models/{modelId}:generateContent
12
+ */
13
+ import { getPiSubProvider, type PiApiFlavor } from './sub-providers.js';
14
+
15
+ export interface PiTestCompletionInput {
16
+ subProvider: string;
17
+ apiKey?: string;
18
+ baseUrl?: string;
19
+ modelId?: string;
20
+ prompt: string;
21
+ }
22
+
23
+ export interface PiTestCompletionResult {
24
+ ok: boolean;
25
+ text?: string;
26
+ error?: string;
27
+ status?: number;
28
+ modelId?: string;
29
+ subProvider?: string;
30
+ }
31
+
32
+ const REQUEST_TIMEOUT_MS = 30_000;
33
+
34
+ async function timedFetch(url: string, init: RequestInit): Promise<Response> {
35
+ const ctl = new AbortController();
36
+ const timer = setTimeout(() => ctl.abort(), REQUEST_TIMEOUT_MS);
37
+ try {
38
+ return await fetch(url, { ...init, signal: ctl.signal });
39
+ } finally {
40
+ clearTimeout(timer);
41
+ }
42
+ }
43
+
44
+ function pickBaseUrl(input: PiTestCompletionInput): string | undefined {
45
+ if (input.baseUrl?.trim()) return input.baseUrl.replace(/\/+$/, '');
46
+ const def = getPiSubProvider(input.subProvider)?.baseUrl;
47
+ return def?.replace(/\/+$/, '');
48
+ }
49
+
50
+ function pickModelId(input: PiTestCompletionInput): string | undefined {
51
+ if (input.modelId?.trim()) return input.modelId.trim();
52
+ return getPiSubProvider(input.subProvider)?.defaultModel;
53
+ }
54
+
55
+ export async function runPiTestCompletion(input: PiTestCompletionInput): Promise<PiTestCompletionResult> {
56
+ const provider = getPiSubProvider(input.subProvider);
57
+ if (!provider) {
58
+ return { ok: false, error: `Unknown sub-provider: ${input.subProvider}` };
59
+ }
60
+
61
+ const baseUrl = pickBaseUrl(input);
62
+ if (!baseUrl) return { ok: false, error: 'Missing base URL' };
63
+
64
+ const modelId = pickModelId(input);
65
+ if (!modelId) return { ok: false, error: 'Missing model ID' };
66
+
67
+ if (provider.needsApiKey && !input.apiKey?.trim()) {
68
+ return { ok: false, error: 'Missing API key' };
69
+ }
70
+
71
+ try {
72
+ const text = await callByFlavor(provider.flavor, {
73
+ baseUrl,
74
+ modelId,
75
+ apiKey: input.apiKey?.trim() || '',
76
+ prompt: input.prompt,
77
+ });
78
+ return { ok: true, text, modelId, subProvider: provider.id };
79
+ } catch (err: any) {
80
+ return {
81
+ ok: false,
82
+ error: err?.message || String(err),
83
+ status: err?.status,
84
+ modelId,
85
+ subProvider: provider.id,
86
+ };
87
+ }
88
+ }
89
+
90
+ interface DispatchArgs {
91
+ baseUrl: string;
92
+ modelId: string;
93
+ apiKey: string;
94
+ prompt: string;
95
+ }
96
+
97
+ async function callByFlavor(flavor: PiApiFlavor, args: DispatchArgs): Promise<string> {
98
+ switch (flavor) {
99
+ case 'openai-completions':
100
+ return callOpenAICompletions(args);
101
+ case 'anthropic-messages':
102
+ return callAnthropicMessages(args);
103
+ case 'google-gemini':
104
+ return callGoogleGemini(args);
105
+ }
106
+ }
107
+
108
+ /* ── OpenAI / OpenAI-compatible ── */
109
+
110
+ async function callOpenAICompletions({ baseUrl, modelId, apiKey, prompt }: DispatchArgs): Promise<string> {
111
+ const headers: Record<string, string> = { 'content-type': 'application/json' };
112
+ if (apiKey) headers['authorization'] = `Bearer ${apiKey}`;
113
+
114
+ const res = await timedFetch(`${baseUrl}/chat/completions`, {
115
+ method: 'POST',
116
+ headers,
117
+ body: JSON.stringify({
118
+ model: modelId,
119
+ messages: [{ role: 'user', content: prompt }],
120
+ max_tokens: 256,
121
+ stream: false,
122
+ }),
123
+ });
124
+
125
+ if (!res.ok) throw await httpError(res);
126
+
127
+ const body: any = await res.json();
128
+ const text = body?.choices?.[0]?.message?.content;
129
+ if (typeof text !== 'string' || !text.trim()) {
130
+ throw new Error(`Empty response (${JSON.stringify(body).slice(0, 200)})`);
131
+ }
132
+ return text.trim();
133
+ }
134
+
135
+ /* ── Anthropic Messages API ── */
136
+
137
+ async function callAnthropicMessages({ baseUrl, modelId, apiKey, prompt }: DispatchArgs): Promise<string> {
138
+ const res = await timedFetch(`${baseUrl}/messages`, {
139
+ method: 'POST',
140
+ headers: {
141
+ 'content-type': 'application/json',
142
+ 'x-api-key': apiKey,
143
+ 'anthropic-version': '2023-06-01',
144
+ },
145
+ body: JSON.stringify({
146
+ model: modelId,
147
+ max_tokens: 256,
148
+ messages: [{ role: 'user', content: prompt }],
149
+ }),
150
+ });
151
+
152
+ if (!res.ok) throw await httpError(res);
153
+
154
+ const body: any = await res.json();
155
+ const block = Array.isArray(body?.content)
156
+ ? body.content.find((b: any) => b?.type === 'text')
157
+ : null;
158
+ const text = block?.text;
159
+ if (typeof text !== 'string' || !text.trim()) {
160
+ throw new Error(`Empty response (${JSON.stringify(body).slice(0, 200)})`);
161
+ }
162
+ return text.trim();
163
+ }
164
+
165
+ /* ── Google Gemini ── */
166
+
167
+ async function callGoogleGemini({ baseUrl, modelId, apiKey, prompt }: DispatchArgs): Promise<string> {
168
+ const url = `${baseUrl}/models/${encodeURIComponent(modelId)}:generateContent?key=${encodeURIComponent(apiKey)}`;
169
+ const res = await timedFetch(url, {
170
+ method: 'POST',
171
+ headers: { 'content-type': 'application/json' },
172
+ body: JSON.stringify({
173
+ contents: [{ role: 'user', parts: [{ text: prompt }] }],
174
+ generationConfig: { maxOutputTokens: 256 },
175
+ }),
176
+ });
177
+
178
+ if (!res.ok) throw await httpError(res);
179
+
180
+ const body: any = await res.json();
181
+ const parts: any[] = body?.candidates?.[0]?.content?.parts || [];
182
+ const text = parts.map((p) => p?.text).filter(Boolean).join('\n').trim();
183
+ if (!text) throw new Error(`Empty response (${JSON.stringify(body).slice(0, 200)})`);
184
+ return text;
185
+ }
186
+
187
+ /* ── Helpers ── */
188
+
189
+ async function httpError(res: Response): Promise<Error> {
190
+ let detail = '';
191
+ try { detail = await res.text(); } catch {}
192
+ const trimmed = detail.length > 400 ? `${detail.slice(0, 400)}…` : detail;
193
+ const err: any = new Error(`HTTP ${res.status} ${res.statusText}${trimmed ? `: ${trimmed}` : ''}`);
194
+ err.status = res.status;
195
+ return err;
196
+ }
@@ -351,6 +351,12 @@ export async function startSupervisor() {
351
351
  'POST /api/auth/codex/start',
352
352
  'POST /api/auth/codex/cancel',
353
353
  'GET /api/auth/codex/status',
354
+ 'GET /api/auth/pi/providers',
355
+ 'GET /api/auth/pi/status',
356
+ 'POST /api/auth/pi/test',
357
+ 'POST /api/auth/pi/save',
358
+ 'DELETE /api/auth/pi',
359
+ 'POST /api/auth/pi/completion',
354
360
  'POST /api/portal/totp/setup',
355
361
  'POST /api/portal/totp/verify-setup',
356
362
  'POST /api/portal/totp/disable',
package/worker/index.ts CHANGED
@@ -16,6 +16,9 @@ import {
16
16
  import { startClaudeOAuth, exchangeClaudeCode, getClaudeAuthStatus, readClaudeAccessToken } from './claude-auth.js';
17
17
  import { checkAvailability, registerHandle, claimReservedHandle, releaseHandle, updateTunnelUrl, startHeartbeat, stopHeartbeat } from '../shared/relay.js';
18
18
  import { ensureFileDirs } from '../supervisor/file-saver.js';
19
+ import { readPiAuth, writePiAuth, clearPiAuth, getPiAuthStatus } from '../supervisor/harnesses/pi/auth-storage.js';
20
+ import { runPiTestCompletion } from '../supervisor/harnesses/pi/test-completion.js';
21
+ import { PI_SUB_PROVIDERS, getPiSubProvider } from '../supervisor/harnesses/pi/sub-providers.js';
19
22
 
20
23
  // ── Password hashing (scrypt) ──
21
24
 
@@ -228,6 +231,89 @@ app.get('/api/auth/claude/status', async (_req, res) => {
228
231
  res.json(await getClaudeAuthStatus());
229
232
  });
230
233
 
234
+ // ── Pi (Bloby third harness) auth routes ──
235
+
236
+ app.get('/api/auth/pi/providers', (_req, res) => {
237
+ res.json({
238
+ providers: PI_SUB_PROVIDERS.map((p) => ({
239
+ id: p.id,
240
+ name: p.name,
241
+ subtitle: p.subtitle,
242
+ flavor: p.flavor,
243
+ baseUrl: p.baseUrl,
244
+ needsBaseUrl: !!p.needsBaseUrl,
245
+ needsApiKey: p.needsApiKey !== false,
246
+ apiKeyUrl: p.apiKeyUrl,
247
+ models: p.models,
248
+ defaultModel: p.defaultModel,
249
+ })),
250
+ });
251
+ });
252
+
253
+ app.get('/api/auth/pi/status', (_req, res) => {
254
+ res.json(getPiAuthStatus());
255
+ });
256
+
257
+ app.post('/api/auth/pi/test', async (req, res) => {
258
+ const { subProvider, apiKey, baseUrl, modelId } = req.body || {};
259
+ if (!subProvider || typeof subProvider !== 'string') {
260
+ res.json({ ok: false, error: 'Missing subProvider' });
261
+ return;
262
+ }
263
+ const provider = getPiSubProvider(subProvider);
264
+ if (!provider) {
265
+ res.json({ ok: false, error: `Unknown sub-provider: ${subProvider}` });
266
+ return;
267
+ }
268
+ const prompt = 'Reply with the single word OK so we can confirm this LLM endpoint is reachable.';
269
+ const result = await runPiTestCompletion({ subProvider, apiKey, baseUrl, modelId, prompt });
270
+ res.json(result);
271
+ });
272
+
273
+ app.post('/api/auth/pi/save', (req, res) => {
274
+ const { subProvider, apiKey, baseUrl, modelId } = req.body || {};
275
+ if (!subProvider || typeof subProvider !== 'string') {
276
+ res.json({ ok: false, error: 'Missing subProvider' });
277
+ return;
278
+ }
279
+ const provider = getPiSubProvider(subProvider);
280
+ if (!provider) {
281
+ res.json({ ok: false, error: `Unknown sub-provider: ${subProvider}` });
282
+ return;
283
+ }
284
+ const saved = writePiAuth({
285
+ subProvider,
286
+ apiKey: typeof apiKey === 'string' ? apiKey : undefined,
287
+ baseUrl: typeof baseUrl === 'string' && baseUrl.trim() ? baseUrl.trim() : provider.baseUrl,
288
+ modelId: typeof modelId === 'string' && modelId.trim() ? modelId.trim() : provider.defaultModel,
289
+ });
290
+ res.json({ ok: true, status: { configured: true, subProvider: saved.subProvider, modelId: saved.modelId, baseUrl: saved.baseUrl } });
291
+ });
292
+
293
+ app.delete('/api/auth/pi', (_req, res) => {
294
+ clearPiAuth();
295
+ res.json({ ok: true });
296
+ });
297
+
298
+ app.post('/api/auth/pi/completion', async (req, res) => {
299
+ const auth = readPiAuth();
300
+ if (!auth) {
301
+ res.json({ ok: false, error: 'Bloby provider is not configured yet' });
302
+ return;
303
+ }
304
+ const prompt = (req.body?.prompt && typeof req.body.prompt === 'string')
305
+ ? req.body.prompt
306
+ : 'In one short sentence, introduce yourself as the model behind this endpoint and confirm the connection is working.';
307
+ const result = await runPiTestCompletion({
308
+ subProvider: auth.subProvider,
309
+ apiKey: auth.apiKey,
310
+ baseUrl: auth.baseUrl,
311
+ modelId: auth.modelId,
312
+ prompt,
313
+ });
314
+ res.json(result);
315
+ });
316
+
231
317
  // ── Handle registration ──
232
318
 
233
319
  app.get('/api/handle/check/:username', async (req, res) => {