bloby-bot 0.47.0 → 0.47.2

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,156 @@
1
+ /**
2
+ * Google Gemini streaming provider.
3
+ *
4
+ * Hand-written equivalent of the slice of pi-ai/providers/google.ts that bloby
5
+ * needs — text streaming via `:streamGenerateContent?alt=sse`. Function-calling
6
+ * is wired up in Phase 2; for now we drop tools and stream text only.
7
+ *
8
+ * Endpoint: POST {baseUrl}/models/{modelId}:streamGenerateContent?alt=sse&key={apiKey}
9
+ * Stream: SSE — each `data: {...}` is one candidate update.
10
+ */
11
+ import type {
12
+ PiStreamRequest,
13
+ PiStreamEvent,
14
+ PiMessage,
15
+ PiContentBlock,
16
+ PiStopReason,
17
+ } from './types.js';
18
+
19
+ /** Walk an SSE byte stream and yield each parsed JSON event. */
20
+ async function* parseSse(res: Response): AsyncIterable<any> {
21
+ if (!res.body) return;
22
+ const reader = res.body.getReader();
23
+ const decoder = new TextDecoder();
24
+ let buffer = '';
25
+ try {
26
+ while (true) {
27
+ const { value, done } = await reader.read();
28
+ if (done) break;
29
+ buffer += decoder.decode(value, { stream: true });
30
+ // SSE event boundary is a blank line. Process every complete event in buffer.
31
+ let idx;
32
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
33
+ const raw = buffer.slice(0, idx);
34
+ buffer = buffer.slice(idx + 2);
35
+ const dataLines = raw.split('\n').filter((l) => l.startsWith('data:'));
36
+ if (!dataLines.length) continue;
37
+ const data = dataLines.map((l) => l.slice(5).trimStart()).join('\n');
38
+ if (!data || data === '[DONE]') continue;
39
+ try {
40
+ yield JSON.parse(data);
41
+ } catch {
42
+ // Skip malformed chunks rather than killing the whole turn.
43
+ }
44
+ }
45
+ }
46
+ } finally {
47
+ try { reader.releaseLock(); } catch {}
48
+ }
49
+ }
50
+
51
+ function toGeminiRole(role: PiMessage['role']): 'user' | 'model' {
52
+ return role === 'assistant' ? 'model' : 'user';
53
+ }
54
+
55
+ function toGeminiParts(content: PiContentBlock[]): any[] {
56
+ const parts: any[] = [];
57
+ for (const b of content) {
58
+ if (b.type === 'text') parts.push({ text: b.text });
59
+ else if (b.type === 'image') parts.push({ inlineData: { mimeType: b.mediaType, data: b.data } });
60
+ // tool_use / tool_result are Phase 2.
61
+ }
62
+ return parts;
63
+ }
64
+
65
+ function mapStopReason(reason?: string): PiStopReason {
66
+ switch (reason) {
67
+ case 'STOP':
68
+ case 'FINISH_REASON_STOP':
69
+ return 'end_turn';
70
+ case 'MAX_TOKENS':
71
+ return 'max_tokens';
72
+ case 'SAFETY':
73
+ case 'RECITATION':
74
+ case 'OTHER':
75
+ return 'error';
76
+ default:
77
+ return 'end_turn';
78
+ }
79
+ }
80
+
81
+ export async function* streamGoogle(req: PiStreamRequest): AsyncIterable<PiStreamEvent> {
82
+ const url =
83
+ `${req.baseUrl.replace(/\/+$/, '')}/models/${encodeURIComponent(req.modelId)}:streamGenerateContent` +
84
+ `?alt=sse&key=${encodeURIComponent(req.apiKey)}`;
85
+
86
+ // Filter out empty messages — Gemini rejects requests with no user content.
87
+ const contents = req.messages
88
+ .filter((m) => m.content.length > 0)
89
+ .map((m) => ({ role: toGeminiRole(m.role), parts: toGeminiParts(m.content) }))
90
+ .filter((m) => m.parts.length > 0);
91
+
92
+ const body: any = {
93
+ contents,
94
+ generationConfig: {
95
+ maxOutputTokens: req.maxOutputTokens ?? 4096,
96
+ },
97
+ };
98
+ if (req.systemPrompt?.trim()) {
99
+ body.systemInstruction = { parts: [{ text: req.systemPrompt }] };
100
+ }
101
+
102
+ let res: Response;
103
+ try {
104
+ res = await fetch(url, {
105
+ method: 'POST',
106
+ headers: { 'content-type': 'application/json' },
107
+ body: JSON.stringify(body),
108
+ signal: req.signal,
109
+ });
110
+ } catch (err: any) {
111
+ yield { type: 'error', error: err?.message || String(err) };
112
+ return;
113
+ }
114
+
115
+ if (!res.ok) {
116
+ let detail = '';
117
+ try { detail = await res.text(); } catch {}
118
+ yield { type: 'error', error: `Google ${res.status} ${res.statusText}${detail ? `: ${detail.slice(0, 400)}` : ''}` };
119
+ return;
120
+ }
121
+
122
+ let accumulated = '';
123
+ let lastFinish: string | undefined;
124
+ let usage: { inputTokens?: number; outputTokens?: number } | undefined;
125
+
126
+ try {
127
+ for await (const chunk of parseSse(res)) {
128
+ const candidate = chunk?.candidates?.[0];
129
+ const parts: any[] = candidate?.content?.parts || [];
130
+ for (const part of parts) {
131
+ if (typeof part?.text === 'string' && part.text.length > 0) {
132
+ accumulated += part.text;
133
+ yield { type: 'text_delta', delta: part.text };
134
+ }
135
+ }
136
+ if (candidate?.finishReason) lastFinish = candidate.finishReason;
137
+ const usageMeta = chunk?.usageMetadata;
138
+ if (usageMeta) {
139
+ usage = {
140
+ inputTokens: usageMeta.promptTokenCount,
141
+ outputTokens: usageMeta.candidatesTokenCount,
142
+ };
143
+ }
144
+ }
145
+ } catch (err: any) {
146
+ if (err?.name === 'AbortError') {
147
+ yield { type: 'done', stopReason: 'aborted' };
148
+ return;
149
+ }
150
+ yield { type: 'error', error: err?.message || String(err) };
151
+ return;
152
+ }
153
+
154
+ if (accumulated) yield { type: 'text_end', text: accumulated };
155
+ yield { type: 'done', stopReason: mapStopReason(lastFinish), usage };
156
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Provider dispatcher.
3
+ *
4
+ * One function that turns a `(flavor, request)` into a `PiStreamEvent`
5
+ * AsyncIterable. The session loop only knows this entry point — provider
6
+ * choice happens here based on the sub-provider's `flavor` field.
7
+ */
8
+ import type { PiApiFlavor } from '../sub-providers.js';
9
+ import type { PiStreamRequest, PiStreamEvent } from './types.js';
10
+ import { streamGoogle } from './stream-google.js';
11
+
12
+ export function streamProvider(flavor: PiApiFlavor, req: PiStreamRequest): AsyncIterable<PiStreamEvent> {
13
+ switch (flavor) {
14
+ case 'google-gemini':
15
+ return streamGoogle(req);
16
+ case 'openai-completions':
17
+ throw new Error('openai-completions streaming is not implemented yet (Phase 3).');
18
+ case 'anthropic-messages':
19
+ throw new Error('anthropic-messages streaming is not implemented yet (Phase 3).');
20
+ }
21
+ }
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Provider-shared types for the pi harness.
3
+ *
4
+ * One unified message + event shape regardless of which underlying LLM API
5
+ * (Google Gemini, OpenAI-compatible /v1/chat/completions, Anthropic Messages)
6
+ * is handling the request. Each provider implements `streamProvider(req): AsyncIterable<StreamEvent>`
7
+ * and the session loop consumes the events without knowing the flavor.
8
+ *
9
+ * Modelled after pi-ai's event vocabulary (text_start/delta/end, toolcall_*,
10
+ * done, error) so we can copy fixes from upstream if needed, but only the
11
+ * fields bloby actually consumes are kept.
12
+ */
13
+
14
+ export type PiRole = 'user' | 'assistant' | 'tool';
15
+
16
+ /** A single content block inside a message. */
17
+ export type PiContentBlock =
18
+ | { type: 'text'; text: string }
19
+ | { type: 'image'; mediaType: string; data: string } // base64
20
+ | { type: 'tool_use'; id: string; name: string; input: any }
21
+ | { type: 'tool_result'; toolUseId: string; content: string; isError?: boolean };
22
+
23
+ export interface PiMessage {
24
+ role: PiRole;
25
+ content: PiContentBlock[];
26
+ }
27
+
28
+ /** Schema for one tool the model can call. Plain JSON Schema for input. */
29
+ export interface PiToolDef {
30
+ name: string;
31
+ description: string;
32
+ inputSchema: Record<string, any>;
33
+ }
34
+
35
+ export interface PiStreamRequest {
36
+ modelId: string;
37
+ baseUrl: string;
38
+ apiKey: string;
39
+ systemPrompt: string;
40
+ messages: PiMessage[];
41
+ tools?: PiToolDef[];
42
+ /** Hard cap on output tokens for a single turn. */
43
+ maxOutputTokens?: number;
44
+ /** Optional abort signal so the session can interrupt in-flight requests. */
45
+ signal?: AbortSignal;
46
+ }
47
+
48
+ export type PiStopReason = 'end_turn' | 'tool_use' | 'max_tokens' | 'error' | 'aborted';
49
+
50
+ export type PiStreamEvent =
51
+ | { type: 'text_delta'; delta: string }
52
+ | { type: 'text_end'; text: string }
53
+ | { type: 'tool_use'; id: string; name: string; input: any }
54
+ | { type: 'done'; stopReason: PiStopReason; usage?: PiUsage }
55
+ | { type: 'error'; error: string };
56
+
57
+ export interface PiUsage {
58
+ inputTokens?: number;
59
+ outputTokens?: number;
60
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Pi agent session — the live conversation loop.
3
+ *
4
+ * Mirrors the *shape* of the Claude harness loop in `harnesses/claude.ts`:
5
+ * - one long-lived session per conversation
6
+ * - user messages arrive via an `AsyncQueue<PiMessage>` input
7
+ * - the loop drains the queue one turn at a time
8
+ * - each turn streams provider events back through a single `onEvent`
9
+ * callback the caller hooked up
10
+ *
11
+ * Phase 1 scope: text-only, no tools. Each user turn = one provider call.
12
+ * Phase 2 will plug tools into the inner loop (model emits `tool_use` →
13
+ * execute → append `tool_result` → re-stream → repeat until `end_turn`).
14
+ *
15
+ * Phase 1 explicitly does NOT spawn sub-agents — Bruno will add those later.
16
+ */
17
+ import { log } from '../../../shared/logger.js';
18
+ import type { PiApiFlavor } from './sub-providers.js';
19
+ import { streamProvider } from './providers/stream.js';
20
+ import type { PiMessage, PiStreamEvent, PiToolDef } from './providers/types.js';
21
+ import type { AsyncQueue } from './async-queue.js';
22
+
23
+ export type PiSessionEvent =
24
+ | { type: 'turn_started' }
25
+ | { type: 'text_delta'; delta: string }
26
+ | { type: 'text_end'; text: string }
27
+ | { type: 'tool_use'; id: string; name: string; input: any } // Phase 2
28
+ | { type: 'turn_complete'; usedFileTools: boolean }
29
+ | { type: 'error'; error: string };
30
+
31
+ export interface PiSessionInit {
32
+ flavor: PiApiFlavor;
33
+ modelId: string;
34
+ baseUrl: string;
35
+ apiKey: string;
36
+ systemPrompt: string;
37
+ /** Pre-loaded history before the first new user turn. */
38
+ initialMessages?: PiMessage[];
39
+ /** Phase 2 wires this through. Empty for Phase 1. */
40
+ tools?: PiToolDef[];
41
+ maxOutputTokens?: number;
42
+ /** Used to interrupt in-flight provider calls when the session ends. */
43
+ abortController: AbortController;
44
+ /** Caller's event sink — translated to bloby's `bot:*` events one layer up. */
45
+ onEvent: (evt: PiSessionEvent) => void;
46
+ }
47
+
48
+ export interface PiSession {
49
+ /** Resolves when the loop exits (queue closed or aborted). */
50
+ run(input: AsyncQueue<PiMessage>): Promise<void>;
51
+ /** Cumulative history including prefilled context and live turns. */
52
+ getMessages(): PiMessage[];
53
+ }
54
+
55
+ const FILE_TOOL_NAMES = new Set(['Write', 'Edit', 'write', 'edit']);
56
+
57
+ export function createPiSession(init: PiSessionInit): PiSession {
58
+ const messages: PiMessage[] = init.initialMessages ? [...init.initialMessages] : [];
59
+
60
+ async function runOneTurn(userMsg: PiMessage): Promise<void> {
61
+ if (init.abortController.signal.aborted) return;
62
+ messages.push(userMsg);
63
+ init.onEvent({ type: 'turn_started' });
64
+
65
+ let accumulated = '';
66
+ const usedTools = new Set<string>();
67
+ let errored = false;
68
+
69
+ try {
70
+ const stream = streamProvider(init.flavor, {
71
+ modelId: init.modelId,
72
+ baseUrl: init.baseUrl,
73
+ apiKey: init.apiKey,
74
+ systemPrompt: init.systemPrompt,
75
+ messages,
76
+ tools: init.tools,
77
+ maxOutputTokens: init.maxOutputTokens,
78
+ signal: init.abortController.signal,
79
+ });
80
+
81
+ for await (const evt of stream as AsyncIterable<PiStreamEvent>) {
82
+ if (init.abortController.signal.aborted) return;
83
+ switch (evt.type) {
84
+ case 'text_delta':
85
+ accumulated += evt.delta;
86
+ init.onEvent({ type: 'text_delta', delta: evt.delta });
87
+ break;
88
+ case 'text_end':
89
+ // Provider gives us the final accumulated text; trust the deltas
90
+ // we already forwarded and reconcile state from here.
91
+ accumulated = evt.text;
92
+ init.onEvent({ type: 'text_end', text: evt.text });
93
+ break;
94
+ case 'tool_use':
95
+ // Phase 2: execute the tool, append a tool_result message, re-stream.
96
+ usedTools.add(evt.name);
97
+ init.onEvent({ type: 'tool_use', id: evt.id, name: evt.name, input: evt.input });
98
+ break;
99
+ case 'error':
100
+ errored = true;
101
+ init.onEvent({ type: 'error', error: evt.error });
102
+ break;
103
+ case 'done':
104
+ // Loop back if the model is waiting on a tool result (Phase 2);
105
+ // for now `tool_use` is impossible since we don't pass tools.
106
+ break;
107
+ }
108
+ }
109
+ } catch (err: any) {
110
+ if (init.abortController.signal.aborted) return;
111
+ errored = true;
112
+ init.onEvent({ type: 'error', error: err?.message || String(err) });
113
+ }
114
+
115
+ if (accumulated) {
116
+ messages.push({ role: 'assistant', content: [{ type: 'text', text: accumulated }] });
117
+ }
118
+ if (!errored) {
119
+ const usedFileTools = Array.from(usedTools).some((t) => FILE_TOOL_NAMES.has(t));
120
+ init.onEvent({ type: 'turn_complete', usedFileTools });
121
+ }
122
+ }
123
+
124
+ return {
125
+ async run(input) {
126
+ for await (const userMsg of input) {
127
+ if (init.abortController.signal.aborted) break;
128
+ try {
129
+ await runOneTurn(userMsg);
130
+ } catch (err: any) {
131
+ log.warn(`[pi/session] Turn failed: ${err?.message || err}`);
132
+ init.onEvent({ type: 'error', error: err?.message || String(err) });
133
+ }
134
+ }
135
+ },
136
+ getMessages() {
137
+ return messages;
138
+ },
139
+ };
140
+ }
@@ -10,7 +10,13 @@
10
10
  * Pro/Max, GitHub Copilot, OpenAI Codex) are deliberately out of scope — they
11
11
  * duplicate auth flows we already ship under the dedicated Claude and OpenAI
12
12
  * Codex harnesses.
13
+ *
14
+ * Per-provider model lists come from `models-catalog.generated.ts`, which is
15
+ * synced from upstream pi via `npm run sync:pi-models`. Sub-providers without
16
+ * a pi mapping (Ollama, LM Studio, custom) stay `'dynamic'` — free-form ID.
13
17
  */
18
+ import { PI_MODELS_CATALOG } from './models-catalog.generated.js';
19
+
14
20
  export type PiApiFlavor = 'openai-completions' | 'anthropic-messages' | 'google-gemini';
15
21
 
16
22
  export interface PiSubProviderModel {
@@ -37,21 +43,26 @@ export interface PiSubProvider {
37
43
  defaultModel?: string;
38
44
  }
39
45
 
46
+ function fromCatalog(key: string): PiSubProviderModel[] | 'dynamic' {
47
+ const list = PI_MODELS_CATALOG[key];
48
+ return list && list.length > 0 ? list : 'dynamic';
49
+ }
50
+
51
+ function defaultFor(key: string): string | undefined {
52
+ return PI_MODELS_CATALOG[key]?.[0]?.id;
53
+ }
54
+
40
55
  export const PI_SUB_PROVIDERS: PiSubProvider[] = [
41
56
  {
42
57
  id: 'google',
43
58
  name: 'Google Gemini',
44
- subtitle: 'Gemini 2.x via AI Studio',
59
+ subtitle: 'AI Studio API key',
45
60
  flavor: 'google-gemini',
46
61
  baseUrl: 'https://generativelanguage.googleapis.com/v1beta',
47
62
  needsApiKey: true,
48
63
  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',
64
+ models: fromCatalog('google'),
65
+ defaultModel: defaultFor('google'),
55
66
  },
56
67
  {
57
68
  id: 'deepseek',
@@ -61,26 +72,19 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
61
72
  baseUrl: 'https://api.deepseek.com/v1',
62
73
  needsApiKey: true,
63
74
  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',
75
+ models: fromCatalog('deepseek'),
76
+ defaultModel: defaultFor('deepseek'),
69
77
  },
70
78
  {
71
79
  id: 'groq',
72
80
  name: 'Groq',
73
- subtitle: 'Fast inference for Llama / Mixtral',
81
+ subtitle: 'Fast inference (Llama / Kimi / Qwen)',
74
82
  flavor: 'openai-completions',
75
83
  baseUrl: 'https://api.groq.com/openai/v1',
76
84
  needsApiKey: true,
77
85
  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',
86
+ models: fromCatalog('groq'),
87
+ defaultModel: defaultFor('groq'),
84
88
  },
85
89
  {
86
90
  id: 'xai',
@@ -90,12 +94,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
90
94
  baseUrl: 'https://api.x.ai/v1',
91
95
  needsApiKey: true,
92
96
  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',
97
+ models: fromCatalog('xai'),
98
+ defaultModel: defaultFor('xai'),
99
99
  },
100
100
  {
101
101
  id: 'cerebras',
@@ -105,11 +105,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
105
105
  baseUrl: 'https://api.cerebras.ai/v1',
106
106
  needsApiKey: true,
107
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',
108
+ models: fromCatalog('cerebras'),
109
+ defaultModel: defaultFor('cerebras'),
113
110
  },
114
111
  {
115
112
  id: 'openrouter',
@@ -119,6 +116,7 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
119
116
  baseUrl: 'https://openrouter.ai/api/v1',
120
117
  needsApiKey: true,
121
118
  apiKeyUrl: 'https://openrouter.ai/keys',
119
+ // OpenRouter has 270+ entries — too many to list. Free-form ID input instead.
122
120
  models: 'dynamic',
123
121
  defaultModel: 'anthropic/claude-sonnet-4',
124
122
  },
@@ -130,11 +128,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
130
128
  baseUrl: 'https://api.mistral.ai/v1',
131
129
  needsApiKey: true,
132
130
  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',
131
+ models: fromCatalog('mistral'),
132
+ defaultModel: defaultFor('mistral'),
138
133
  },
139
134
  {
140
135
  id: 'openai-api',
@@ -144,13 +139,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
144
139
  baseUrl: 'https://api.openai.com/v1',
145
140
  needsApiKey: true,
146
141
  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',
142
+ models: fromCatalog('openai-api'),
143
+ defaultModel: defaultFor('openai-api'),
154
144
  },
155
145
  {
156
146
  id: 'anthropic-api',
@@ -160,12 +150,8 @@ export const PI_SUB_PROVIDERS: PiSubProvider[] = [
160
150
  baseUrl: 'https://api.anthropic.com/v1',
161
151
  needsApiKey: true,
162
152
  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',
153
+ models: fromCatalog('anthropic-api'),
154
+ defaultModel: defaultFor('anthropic-api'),
169
155
  },
170
156
  {
171
157
  id: 'ollama',
@@ -1348,10 +1348,11 @@ ${!connected ? `<script>
1348
1348
  log.info(`[bloby] provider=${freshConfig.ai.provider}, model=${freshConfig.ai.model}`);
1349
1349
 
1350
1350
  // Route through the agent harness for any provider that has one
1351
- // (Anthropic → Claude SDK, OpenAI → Codex app-server). The dispatcher
1352
- // in bloby-agent.ts picks the right harness; both use OAuth tokens
1353
- // from their own credentials files, not config.ai.apiKey.
1354
- if (freshConfig.ai.provider === 'anthropic' || freshConfig.ai.provider === 'openai') {
1351
+ // (Anthropic → Claude SDK, OpenAI → Codex app-server, Bloby/pi → pi
1352
+ // harness). The dispatcher in bloby-agent.ts picks the right harness;
1353
+ // credentials live next to each harness (claude.json, codex auth.json,
1354
+ // pi-auth.json) not in config.ai.apiKey.
1355
+ if (freshConfig.ai.provider === 'anthropic' || freshConfig.ai.provider === 'openai' || freshConfig.ai.provider === 'pi') {
1355
1356
  // Server-side persistence: create or reuse DB conversation, save user message
1356
1357
  (async () => {
1357
1358
  // Save attachments to disk (before try so it's accessible in startBlobyAgentQuery below)