bloby-bot 0.47.7 → 0.47.9
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/dist-bloby/assets/{bloby-E-QLmQDW.js → bloby-BOSem5eq.js} +4 -4
- package/dist-bloby/assets/globals-CZdsyZot.css +2 -0
- package/dist-bloby/assets/globals-CgIIDNVG.js +18 -0
- package/dist-bloby/assets/{highlighted-body-OFNGDK62-CTiboTVa.js → highlighted-body-OFNGDK62-DpMYfn6Z.js} +1 -1
- package/dist-bloby/assets/mermaid-GHXKKRXX-B5_2sDIG.js +1 -0
- package/dist-bloby/assets/{onboard-C1uMxuk2.js → onboard-8AOYU7vY.js} +1 -1
- package/dist-bloby/bloby.html +3 -3
- package/dist-bloby/onboard.html +3 -3
- package/package.json +4 -4
- package/supervisor/chat/OnboardWizard.tsx +121 -178
- package/supervisor/harnesses/pi/async-queue.ts +10 -0
- package/supervisor/harnesses/pi/index.ts +21 -0
- package/supervisor/harnesses/pi/providers/stream-anthropic.ts +318 -0
- package/supervisor/harnesses/pi/providers/stream-openai-completions.ts +328 -0
- package/supervisor/harnesses/pi/providers/stream.ts +4 -2
- package/supervisor/harnesses/pi/session.ts +24 -5
- package/workspace/client/public/icons/pi.svg +20 -0
- package/dist-bloby/assets/globals-Ci0CEj1X.js +0 -18
- package/dist-bloby/assets/globals-DriF_8Q_.css +0 -2
- package/dist-bloby/assets/mermaid-GHXKKRXX-CgVqYCFU.js +0 -1
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic Messages-API streaming provider.
|
|
3
|
+
*
|
|
4
|
+
* Different from the existing Claude harness: that one uses the Claude Agent
|
|
5
|
+
* SDK + subscription OAuth, this one talks directly to `api.anthropic.com/v1/messages`
|
|
6
|
+
* with a pay-per-token API key. Lets users bring their own Anthropic credentials
|
|
7
|
+
* through the pi flow instead of (or alongside) the subscription path.
|
|
8
|
+
*
|
|
9
|
+
* Wire shape: SSE with typed events — `message_start`, `content_block_start`,
|
|
10
|
+
* `content_block_delta`, `content_block_stop`, `message_delta`, `message_stop`,
|
|
11
|
+
* `ping`, `error`. Each event's `data:` JSON carries a `type` field matching
|
|
12
|
+
* the event name, so we ignore the SSE `event:` line and route off the JSON.
|
|
13
|
+
*/
|
|
14
|
+
import { log } from '../../../../shared/logger.js';
|
|
15
|
+
import type {
|
|
16
|
+
PiStreamRequest,
|
|
17
|
+
PiStreamEvent,
|
|
18
|
+
PiMessage,
|
|
19
|
+
PiContentBlock,
|
|
20
|
+
PiStopReason,
|
|
21
|
+
} from './types.js';
|
|
22
|
+
|
|
23
|
+
/* ── SSE parser (shares the LF/CRLF-tolerant pattern from the other providers) ── */
|
|
24
|
+
|
|
25
|
+
async function* parseSse(res: Response): AsyncIterable<any> {
|
|
26
|
+
if (!res.body) return;
|
|
27
|
+
const reader = res.body.getReader();
|
|
28
|
+
const decoder = new TextDecoder();
|
|
29
|
+
let buffer = '';
|
|
30
|
+
try {
|
|
31
|
+
while (true) {
|
|
32
|
+
const { value, done } = await reader.read();
|
|
33
|
+
if (done) break;
|
|
34
|
+
buffer += decoder.decode(value, { stream: true });
|
|
35
|
+
let idx;
|
|
36
|
+
while (
|
|
37
|
+
(idx = (() => {
|
|
38
|
+
const a = buffer.indexOf('\n\n');
|
|
39
|
+
const b = buffer.indexOf('\r\n\r\n');
|
|
40
|
+
if (a < 0) return b;
|
|
41
|
+
if (b < 0) return a;
|
|
42
|
+
return Math.min(a, b);
|
|
43
|
+
})()) !== -1
|
|
44
|
+
) {
|
|
45
|
+
const isCrlf = buffer.slice(idx, idx + 4) === '\r\n\r\n';
|
|
46
|
+
const raw = buffer.slice(0, idx);
|
|
47
|
+
buffer = buffer.slice(idx + (isCrlf ? 4 : 2));
|
|
48
|
+
const parsed = parseEvent(raw);
|
|
49
|
+
if (parsed !== undefined) yield parsed;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
buffer += decoder.decode();
|
|
53
|
+
if (buffer.trim()) {
|
|
54
|
+
const parsed = parseEvent(buffer);
|
|
55
|
+
if (parsed !== undefined) yield parsed;
|
|
56
|
+
}
|
|
57
|
+
} finally {
|
|
58
|
+
try { reader.releaseLock(); } catch {}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function parseEvent(raw: string): any | undefined {
|
|
63
|
+
const lines = raw.split(/\r?\n/);
|
|
64
|
+
const dataLines = lines
|
|
65
|
+
.filter((l) => l.startsWith('data:'))
|
|
66
|
+
.map((l) => l.slice(5).trimStart());
|
|
67
|
+
if (!dataLines.length) return undefined;
|
|
68
|
+
const data = dataLines.join('\n');
|
|
69
|
+
if (!data) return undefined;
|
|
70
|
+
try { return JSON.parse(data); } catch { return undefined; }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ── Message conversion (pi → Anthropic) ── */
|
|
74
|
+
|
|
75
|
+
function toAnthropicContent(blocks: PiContentBlock[]): any[] {
|
|
76
|
+
const out: any[] = [];
|
|
77
|
+
for (const b of blocks) {
|
|
78
|
+
if (b.type === 'text') {
|
|
79
|
+
out.push({ type: 'text', text: b.text });
|
|
80
|
+
} else if (b.type === 'image') {
|
|
81
|
+
out.push({
|
|
82
|
+
type: 'image',
|
|
83
|
+
source: { type: 'base64', media_type: b.mediaType, data: b.data },
|
|
84
|
+
});
|
|
85
|
+
} else if (b.type === 'tool_use') {
|
|
86
|
+
out.push({
|
|
87
|
+
type: 'tool_use',
|
|
88
|
+
id: b.id,
|
|
89
|
+
name: b.name,
|
|
90
|
+
input: b.input || {},
|
|
91
|
+
});
|
|
92
|
+
} else if (b.type === 'tool_result') {
|
|
93
|
+
out.push({
|
|
94
|
+
type: 'tool_result',
|
|
95
|
+
tool_use_id: b.toolUseId,
|
|
96
|
+
content: b.content,
|
|
97
|
+
is_error: b.isError || false,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function toAnthropicMessages(pi: PiMessage[]): any[] {
|
|
105
|
+
return pi
|
|
106
|
+
.filter((m) => m.content.length > 0)
|
|
107
|
+
.map((m) => ({
|
|
108
|
+
role: m.role === 'assistant' ? 'assistant' : 'user',
|
|
109
|
+
content: toAnthropicContent(m.content),
|
|
110
|
+
}))
|
|
111
|
+
.filter((m) => m.content.length > 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function toAnthropicTools(tools: { name: string; description: string; inputSchema: Record<string, any> }[]) {
|
|
115
|
+
return tools.map((t) => ({
|
|
116
|
+
name: t.name,
|
|
117
|
+
description: t.description,
|
|
118
|
+
input_schema: t.inputSchema,
|
|
119
|
+
}));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function mapStopReason(reason?: string): PiStopReason {
|
|
123
|
+
switch (reason) {
|
|
124
|
+
case 'end_turn': return 'end_turn';
|
|
125
|
+
case 'stop_sequence': return 'end_turn';
|
|
126
|
+
case 'max_tokens': return 'max_tokens';
|
|
127
|
+
case 'tool_use': return 'tool_use';
|
|
128
|
+
case 'pause_turn': return 'end_turn';
|
|
129
|
+
case 'refusal': return 'error';
|
|
130
|
+
default: return 'end_turn';
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/* ── Streaming entry point ── */
|
|
135
|
+
|
|
136
|
+
interface PartialBlock {
|
|
137
|
+
kind: 'text' | 'tool_use' | 'other';
|
|
138
|
+
text?: string;
|
|
139
|
+
toolUseId?: string;
|
|
140
|
+
toolName?: string;
|
|
141
|
+
toolArgsBuf?: string;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function* streamAnthropic(req: PiStreamRequest): AsyncIterable<PiStreamEvent> {
|
|
145
|
+
const url = `${req.baseUrl.replace(/\/+$/, '')}/messages`;
|
|
146
|
+
|
|
147
|
+
const body: any = {
|
|
148
|
+
model: req.modelId,
|
|
149
|
+
messages: toAnthropicMessages(req.messages),
|
|
150
|
+
max_tokens: req.maxOutputTokens ?? 8192,
|
|
151
|
+
stream: true,
|
|
152
|
+
};
|
|
153
|
+
if (req.systemPrompt?.trim()) body.system = req.systemPrompt;
|
|
154
|
+
if (req.tools && req.tools.length > 0) body.tools = toAnthropicTools(req.tools);
|
|
155
|
+
|
|
156
|
+
let res: Response;
|
|
157
|
+
try {
|
|
158
|
+
res = await fetch(url, {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
headers: {
|
|
161
|
+
'content-type': 'application/json',
|
|
162
|
+
'accept': 'text/event-stream',
|
|
163
|
+
'x-api-key': req.apiKey,
|
|
164
|
+
'anthropic-version': '2023-06-01',
|
|
165
|
+
},
|
|
166
|
+
body: JSON.stringify(body),
|
|
167
|
+
signal: req.signal,
|
|
168
|
+
});
|
|
169
|
+
} catch (err: any) {
|
|
170
|
+
yield { type: 'error', error: err?.message || String(err) };
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (!res.ok) {
|
|
175
|
+
let detail = '';
|
|
176
|
+
try { detail = await res.text(); } catch {}
|
|
177
|
+
yield { type: 'error', error: `Anthropic ${res.status} ${res.statusText}${detail ? `: ${detail.slice(0, 400)}` : ''}` };
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Anthropic streams content blocks by index. Track partial state per index
|
|
182
|
+
// so deltas land on the right block.
|
|
183
|
+
const blocks = new Map<number, PartialBlock>();
|
|
184
|
+
let accumulated = '';
|
|
185
|
+
let lastStop: string | undefined;
|
|
186
|
+
let usage: { inputTokens?: number; outputTokens?: number } | undefined;
|
|
187
|
+
let chunkCount = 0;
|
|
188
|
+
let firstChunkSummary = '';
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
for await (const evt of parseSse(res)) {
|
|
192
|
+
chunkCount++;
|
|
193
|
+
if (chunkCount === 1) {
|
|
194
|
+
try { firstChunkSummary = JSON.stringify(evt).slice(0, 600); } catch {}
|
|
195
|
+
}
|
|
196
|
+
const type = evt?.type;
|
|
197
|
+
switch (type) {
|
|
198
|
+
case 'message_start': {
|
|
199
|
+
const u = evt?.message?.usage;
|
|
200
|
+
if (u) usage = { inputTokens: u.input_tokens, outputTokens: u.output_tokens };
|
|
201
|
+
break;
|
|
202
|
+
}
|
|
203
|
+
case 'content_block_start': {
|
|
204
|
+
const idx = evt?.index ?? 0;
|
|
205
|
+
const block = evt?.content_block || {};
|
|
206
|
+
if (block.type === 'text') {
|
|
207
|
+
blocks.set(idx, { kind: 'text', text: '' });
|
|
208
|
+
} else if (block.type === 'tool_use') {
|
|
209
|
+
blocks.set(idx, {
|
|
210
|
+
kind: 'tool_use',
|
|
211
|
+
toolUseId: block.id,
|
|
212
|
+
toolName: block.name,
|
|
213
|
+
toolArgsBuf: '',
|
|
214
|
+
});
|
|
215
|
+
} else {
|
|
216
|
+
blocks.set(idx, { kind: 'other' });
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
case 'content_block_delta': {
|
|
221
|
+
const idx = evt?.index ?? 0;
|
|
222
|
+
const delta = evt?.delta || {};
|
|
223
|
+
const slot = blocks.get(idx);
|
|
224
|
+
if (!slot) break;
|
|
225
|
+
if (delta.type === 'text_delta' && typeof delta.text === 'string') {
|
|
226
|
+
slot.text = (slot.text || '') + delta.text;
|
|
227
|
+
accumulated += delta.text;
|
|
228
|
+
yield { type: 'text_delta', delta: delta.text };
|
|
229
|
+
} else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
230
|
+
slot.toolArgsBuf = (slot.toolArgsBuf || '') + delta.partial_json;
|
|
231
|
+
}
|
|
232
|
+
// `thinking_delta` (extended thinking) is ignored for now — the
|
|
233
|
+
// pi harness doesn't surface reasoning to the user yet.
|
|
234
|
+
break;
|
|
235
|
+
}
|
|
236
|
+
case 'content_block_stop': {
|
|
237
|
+
const idx = evt?.index ?? 0;
|
|
238
|
+
const slot = blocks.get(idx);
|
|
239
|
+
if (!slot) break;
|
|
240
|
+
if (slot.kind === 'tool_use' && slot.toolUseId && slot.toolName) {
|
|
241
|
+
let input: any = {};
|
|
242
|
+
if (slot.toolArgsBuf) {
|
|
243
|
+
try { input = JSON.parse(slot.toolArgsBuf); }
|
|
244
|
+
catch { input = { _raw: slot.toolArgsBuf }; }
|
|
245
|
+
}
|
|
246
|
+
yield {
|
|
247
|
+
type: 'tool_use',
|
|
248
|
+
id: slot.toolUseId,
|
|
249
|
+
name: slot.toolName,
|
|
250
|
+
input,
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
break;
|
|
254
|
+
}
|
|
255
|
+
case 'message_delta': {
|
|
256
|
+
if (evt?.delta?.stop_reason) lastStop = evt.delta.stop_reason;
|
|
257
|
+
const u = evt?.usage;
|
|
258
|
+
if (u && (u.output_tokens !== undefined || u.input_tokens !== undefined)) {
|
|
259
|
+
usage = {
|
|
260
|
+
inputTokens: u.input_tokens ?? usage?.inputTokens,
|
|
261
|
+
outputTokens: u.output_tokens ?? usage?.outputTokens,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
case 'error': {
|
|
267
|
+
const msg = evt?.error?.message || evt?.message || 'Unknown error';
|
|
268
|
+
yield { type: 'error', error: `Anthropic stream error: ${msg}` };
|
|
269
|
+
yield { type: 'done', stopReason: 'error', usage };
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
case 'message_stop':
|
|
273
|
+
case 'ping':
|
|
274
|
+
default:
|
|
275
|
+
// ping is keep-alive; message_stop is bookkeeping.
|
|
276
|
+
break;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
} catch (err: any) {
|
|
280
|
+
if (err?.name === 'AbortError') {
|
|
281
|
+
yield { type: 'done', stopReason: 'aborted' };
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
yield { type: 'error', error: err?.message || String(err) };
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const hadToolUse = Array.from(blocks.values()).some((b) => b.kind === 'tool_use');
|
|
289
|
+
|
|
290
|
+
log.info(
|
|
291
|
+
`[pi/anthropic] stream done — chunks=${chunkCount} text=${accumulated.length} ` +
|
|
292
|
+
`toolCalls=${Array.from(blocks.values()).filter((b) => b.kind === 'tool_use').length} ` +
|
|
293
|
+
`stopReason=${lastStop || 'none'} ` +
|
|
294
|
+
`promptTok=${usage?.inputTokens ?? '?'} outTok=${usage?.outputTokens ?? '?'}`,
|
|
295
|
+
);
|
|
296
|
+
if (chunkCount === 0) {
|
|
297
|
+
log.warn(`[pi/anthropic] zero chunks parsed — content-type=${res.headers.get('content-type') || '?'}`);
|
|
298
|
+
} else if (!accumulated && !hadToolUse) {
|
|
299
|
+
log.info(`[pi/anthropic] first chunk (truncated): ${firstChunkSummary}`);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (accumulated) yield { type: 'text_end', text: accumulated };
|
|
303
|
+
|
|
304
|
+
if (!accumulated && !hadToolUse) {
|
|
305
|
+
yield {
|
|
306
|
+
type: 'error',
|
|
307
|
+
error: `Anthropic returned no output (stopReason=${lastStop || 'unknown'}).`,
|
|
308
|
+
};
|
|
309
|
+
yield { type: 'done', stopReason: 'error', usage };
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
yield {
|
|
314
|
+
type: 'done',
|
|
315
|
+
stopReason: hadToolUse ? 'tool_use' : mapStopReason(lastStop),
|
|
316
|
+
usage,
|
|
317
|
+
};
|
|
318
|
+
}
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI-completions streaming provider.
|
|
3
|
+
*
|
|
4
|
+
* One wire protocol — `POST {baseUrl}/chat/completions` with `stream: true`
|
|
5
|
+
* and SSE — covers a long list of vendors:
|
|
6
|
+
* OpenAI, DeepSeek, Groq, xAI (Grok), Cerebras, OpenRouter, Mistral,
|
|
7
|
+
* Ollama, LM Studio, plus any custom `/v1`-compatible endpoint.
|
|
8
|
+
*
|
|
9
|
+
* Only base URL + auth key change between them, so this single
|
|
10
|
+
* implementation is the workhorse of the pi harness.
|
|
11
|
+
*/
|
|
12
|
+
import { log } from '../../../../shared/logger.js';
|
|
13
|
+
import type {
|
|
14
|
+
PiStreamRequest,
|
|
15
|
+
PiStreamEvent,
|
|
16
|
+
PiMessage,
|
|
17
|
+
PiContentBlock,
|
|
18
|
+
PiStopReason,
|
|
19
|
+
} from './types.js';
|
|
20
|
+
|
|
21
|
+
/* ── SSE parser (LF or CRLF tolerant, flushes the trailing event) ── */
|
|
22
|
+
|
|
23
|
+
async function* parseSse(res: Response): AsyncIterable<any> {
|
|
24
|
+
if (!res.body) return;
|
|
25
|
+
const reader = res.body.getReader();
|
|
26
|
+
const decoder = new TextDecoder();
|
|
27
|
+
let buffer = '';
|
|
28
|
+
try {
|
|
29
|
+
while (true) {
|
|
30
|
+
const { value, done } = await reader.read();
|
|
31
|
+
if (done) break;
|
|
32
|
+
buffer += decoder.decode(value, { stream: true });
|
|
33
|
+
let idx;
|
|
34
|
+
while (
|
|
35
|
+
(idx = (() => {
|
|
36
|
+
const a = buffer.indexOf('\n\n');
|
|
37
|
+
const b = buffer.indexOf('\r\n\r\n');
|
|
38
|
+
if (a < 0) return b;
|
|
39
|
+
if (b < 0) return a;
|
|
40
|
+
return Math.min(a, b);
|
|
41
|
+
})()) !== -1
|
|
42
|
+
) {
|
|
43
|
+
const isCrlf = buffer.slice(idx, idx + 4) === '\r\n\r\n';
|
|
44
|
+
const raw = buffer.slice(0, idx);
|
|
45
|
+
buffer = buffer.slice(idx + (isCrlf ? 4 : 2));
|
|
46
|
+
const parsed = parseEvent(raw);
|
|
47
|
+
if (parsed !== undefined) yield parsed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
buffer += decoder.decode();
|
|
51
|
+
if (buffer.trim()) {
|
|
52
|
+
const parsed = parseEvent(buffer);
|
|
53
|
+
if (parsed !== undefined) yield parsed;
|
|
54
|
+
}
|
|
55
|
+
} finally {
|
|
56
|
+
try { reader.releaseLock(); } catch {}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseEvent(raw: string): any | undefined {
|
|
61
|
+
const lines = raw.split(/\r?\n/);
|
|
62
|
+
const dataLines = lines
|
|
63
|
+
.filter((l) => l.startsWith('data:'))
|
|
64
|
+
.map((l) => l.slice(5).trimStart());
|
|
65
|
+
if (!dataLines.length) return undefined;
|
|
66
|
+
const data = dataLines.join('\n');
|
|
67
|
+
if (!data || data === '[DONE]') return undefined;
|
|
68
|
+
try { return JSON.parse(data); } catch { return undefined; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/* ── Message conversion (pi → OpenAI) ── */
|
|
72
|
+
|
|
73
|
+
function isToolResultMessage(m: PiMessage): boolean {
|
|
74
|
+
return m.role === 'user' && m.content.length > 0 && m.content.every((b) => b.type === 'tool_result');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The OpenAI Chat Completions schema wants tool results as their own
|
|
79
|
+
* `role: "tool"` messages, one per result, immediately following the
|
|
80
|
+
* assistant message that emitted the tool_calls. Our unified PiMessage
|
|
81
|
+
* keeps tool_result blocks inside a user-role message instead — split them
|
|
82
|
+
* here so the wire payload is valid.
|
|
83
|
+
*/
|
|
84
|
+
function toOpenAIMessages(pi: PiMessage[]): any[] {
|
|
85
|
+
const out: any[] = [];
|
|
86
|
+
for (const m of pi) {
|
|
87
|
+
if (isToolResultMessage(m)) {
|
|
88
|
+
for (const block of m.content) {
|
|
89
|
+
if (block.type !== 'tool_result') continue;
|
|
90
|
+
out.push({
|
|
91
|
+
role: 'tool',
|
|
92
|
+
tool_call_id: block.toolUseId,
|
|
93
|
+
content: block.content,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (m.role === 'assistant') {
|
|
99
|
+
const textParts: string[] = [];
|
|
100
|
+
const toolCalls: any[] = [];
|
|
101
|
+
for (const b of m.content) {
|
|
102
|
+
if (b.type === 'text') textParts.push(b.text);
|
|
103
|
+
else if (b.type === 'tool_use') {
|
|
104
|
+
toolCalls.push({
|
|
105
|
+
id: b.id,
|
|
106
|
+
type: 'function',
|
|
107
|
+
function: {
|
|
108
|
+
name: b.name,
|
|
109
|
+
arguments: JSON.stringify(b.input || {}),
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const msg: any = { role: 'assistant', content: textParts.join('') || null };
|
|
115
|
+
if (toolCalls.length > 0) msg.tool_calls = toolCalls;
|
|
116
|
+
out.push(msg);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// role === 'user' with non-tool-result content (text + optional images)
|
|
120
|
+
const contentBlocks: any[] = [];
|
|
121
|
+
let plainText = '';
|
|
122
|
+
let hasImage = false;
|
|
123
|
+
for (const b of m.content) {
|
|
124
|
+
if (b.type === 'text') {
|
|
125
|
+
plainText += (plainText ? '\n' : '') + b.text;
|
|
126
|
+
} else if (b.type === 'image') {
|
|
127
|
+
hasImage = true;
|
|
128
|
+
contentBlocks.push({
|
|
129
|
+
type: 'image_url',
|
|
130
|
+
image_url: { url: `data:${b.mediaType};base64,${b.data}` },
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (hasImage) {
|
|
135
|
+
// Mixed image+text: prepend text part to the content array.
|
|
136
|
+
if (plainText) contentBlocks.unshift({ type: 'text', text: plainText });
|
|
137
|
+
out.push({ role: 'user', content: contentBlocks });
|
|
138
|
+
} else {
|
|
139
|
+
out.push({ role: 'user', content: plainText });
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function toOpenAITools(tools: { name: string; description: string; inputSchema: Record<string, any> }[]) {
|
|
146
|
+
return tools.map((t) => ({
|
|
147
|
+
type: 'function',
|
|
148
|
+
function: {
|
|
149
|
+
name: t.name,
|
|
150
|
+
description: t.description,
|
|
151
|
+
parameters: t.inputSchema,
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function mapFinishReason(reason?: string): PiStopReason {
|
|
157
|
+
switch (reason) {
|
|
158
|
+
case 'stop': return 'end_turn';
|
|
159
|
+
case 'length': return 'max_tokens';
|
|
160
|
+
case 'tool_calls':
|
|
161
|
+
case 'function_call':
|
|
162
|
+
return 'tool_use';
|
|
163
|
+
case 'content_filter': return 'error';
|
|
164
|
+
default: return 'end_turn';
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/* ── Streaming entry point ── */
|
|
169
|
+
|
|
170
|
+
interface PartialToolCall {
|
|
171
|
+
id: string;
|
|
172
|
+
name: string;
|
|
173
|
+
argsBuf: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export async function* streamOpenAICompletions(req: PiStreamRequest): AsyncIterable<PiStreamEvent> {
|
|
177
|
+
const url = `${req.baseUrl.replace(/\/+$/, '')}/chat/completions`;
|
|
178
|
+
|
|
179
|
+
const openaiMessages = toOpenAIMessages(req.messages);
|
|
180
|
+
// Inline the system prompt as the first message — most providers honour it
|
|
181
|
+
// there; the few that prefer `instructions` (Responses API) aren't us.
|
|
182
|
+
if (req.systemPrompt?.trim()) {
|
|
183
|
+
openaiMessages.unshift({ role: 'system', content: req.systemPrompt });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const body: any = {
|
|
187
|
+
model: req.modelId,
|
|
188
|
+
messages: openaiMessages,
|
|
189
|
+
stream: true,
|
|
190
|
+
max_tokens: req.maxOutputTokens ?? 8192,
|
|
191
|
+
};
|
|
192
|
+
if (req.tools && req.tools.length > 0) {
|
|
193
|
+
body.tools = toOpenAITools(req.tools);
|
|
194
|
+
body.tool_choice = 'auto';
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let res: Response;
|
|
198
|
+
try {
|
|
199
|
+
const headers: Record<string, string> = {
|
|
200
|
+
'content-type': 'application/json',
|
|
201
|
+
'accept': 'text/event-stream',
|
|
202
|
+
};
|
|
203
|
+
if (req.apiKey) headers['authorization'] = `Bearer ${req.apiKey}`;
|
|
204
|
+
res = await fetch(url, {
|
|
205
|
+
method: 'POST',
|
|
206
|
+
headers,
|
|
207
|
+
body: JSON.stringify(body),
|
|
208
|
+
signal: req.signal,
|
|
209
|
+
});
|
|
210
|
+
} catch (err: any) {
|
|
211
|
+
yield { type: 'error', error: err?.message || String(err) };
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!res.ok) {
|
|
216
|
+
let detail = '';
|
|
217
|
+
try { detail = await res.text(); } catch {}
|
|
218
|
+
yield { type: 'error', error: `OpenAI-compat ${res.status} ${res.statusText}${detail ? `: ${detail.slice(0, 400)}` : ''}` };
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let accumulated = '';
|
|
223
|
+
let lastFinish: string | undefined;
|
|
224
|
+
let usage: { inputTokens?: number; outputTokens?: number } | undefined;
|
|
225
|
+
const toolCallsByIndex = new Map<number, PartialToolCall>();
|
|
226
|
+
let chunkCount = 0;
|
|
227
|
+
let firstChunkSummary = '';
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
for await (const chunk of parseSse(res)) {
|
|
231
|
+
chunkCount++;
|
|
232
|
+
if (chunkCount === 1) {
|
|
233
|
+
try { firstChunkSummary = JSON.stringify(chunk).slice(0, 600); } catch {}
|
|
234
|
+
}
|
|
235
|
+
const choice = chunk?.choices?.[0];
|
|
236
|
+
if (!choice) {
|
|
237
|
+
if (chunk?.usage) {
|
|
238
|
+
usage = {
|
|
239
|
+
inputTokens: chunk.usage.prompt_tokens,
|
|
240
|
+
outputTokens: chunk.usage.completion_tokens,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
const delta = choice.delta || {};
|
|
246
|
+
|
|
247
|
+
if (typeof delta.content === 'string' && delta.content.length > 0) {
|
|
248
|
+
accumulated += delta.content;
|
|
249
|
+
yield { type: 'text_delta', delta: delta.content };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Tool-call deltas: function name + arguments stream in pieces keyed by
|
|
253
|
+
// `index`. Accumulate per index and only emit the full tool_use once we
|
|
254
|
+
// see finish_reason: 'tool_calls' (or at stream end).
|
|
255
|
+
const toolDeltas: any[] = Array.isArray(delta.tool_calls) ? delta.tool_calls : [];
|
|
256
|
+
for (const td of toolDeltas) {
|
|
257
|
+
const idx = typeof td.index === 'number' ? td.index : 0;
|
|
258
|
+
let partial = toolCallsByIndex.get(idx);
|
|
259
|
+
if (!partial) {
|
|
260
|
+
partial = { id: td.id || '', name: '', argsBuf: '' };
|
|
261
|
+
toolCallsByIndex.set(idx, partial);
|
|
262
|
+
}
|
|
263
|
+
if (td.id) partial.id = td.id;
|
|
264
|
+
const fn = td.function || {};
|
|
265
|
+
if (fn.name) partial.name = fn.name;
|
|
266
|
+
if (typeof fn.arguments === 'string') partial.argsBuf += fn.arguments;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (choice.finish_reason) lastFinish = choice.finish_reason;
|
|
270
|
+
if (chunk?.usage) {
|
|
271
|
+
usage = {
|
|
272
|
+
inputTokens: chunk.usage.prompt_tokens,
|
|
273
|
+
outputTokens: chunk.usage.completion_tokens,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch (err: any) {
|
|
278
|
+
if (err?.name === 'AbortError') {
|
|
279
|
+
yield { type: 'done', stopReason: 'aborted' };
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
yield { type: 'error', error: err?.message || String(err) };
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
log.info(
|
|
287
|
+
`[pi/openai-compat] stream done — chunks=${chunkCount} text=${accumulated.length} ` +
|
|
288
|
+
`toolCalls=${toolCallsByIndex.size} finishReason=${lastFinish || 'none'} ` +
|
|
289
|
+
`promptTok=${usage?.inputTokens ?? '?'} outTok=${usage?.outputTokens ?? '?'}`,
|
|
290
|
+
);
|
|
291
|
+
if (chunkCount === 0) {
|
|
292
|
+
log.warn(`[pi/openai-compat] zero chunks parsed — content-type=${res.headers.get('content-type') || '?'}`);
|
|
293
|
+
} else if (!accumulated && toolCallsByIndex.size === 0) {
|
|
294
|
+
log.info(`[pi/openai-compat] first chunk (truncated): ${firstChunkSummary}`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (accumulated) yield { type: 'text_end', text: accumulated };
|
|
298
|
+
|
|
299
|
+
for (const partial of toolCallsByIndex.values()) {
|
|
300
|
+
let input: any = {};
|
|
301
|
+
if (partial.argsBuf) {
|
|
302
|
+
try { input = JSON.parse(partial.argsBuf); }
|
|
303
|
+
catch { input = { _raw: partial.argsBuf }; }
|
|
304
|
+
}
|
|
305
|
+
yield {
|
|
306
|
+
type: 'tool_use',
|
|
307
|
+
id: partial.id || `call_${partial.name}_${Math.random().toString(36).slice(2, 10)}`,
|
|
308
|
+
name: partial.name,
|
|
309
|
+
input,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
if (!accumulated && toolCallsByIndex.size === 0) {
|
|
314
|
+
yield {
|
|
315
|
+
type: 'error',
|
|
316
|
+
error: `Provider returned no output (finishReason=${lastFinish || 'unknown'}). ` +
|
|
317
|
+
`This usually means the model rejected the request or hit a content filter.`,
|
|
318
|
+
};
|
|
319
|
+
yield { type: 'done', stopReason: 'error', usage };
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
yield {
|
|
324
|
+
type: 'done',
|
|
325
|
+
stopReason: toolCallsByIndex.size > 0 ? 'tool_use' : mapFinishReason(lastFinish),
|
|
326
|
+
usage,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
@@ -8,14 +8,16 @@
|
|
|
8
8
|
import type { PiApiFlavor } from '../sub-providers.js';
|
|
9
9
|
import type { PiStreamRequest, PiStreamEvent } from './types.js';
|
|
10
10
|
import { streamGoogle } from './stream-google.js';
|
|
11
|
+
import { streamOpenAICompletions } from './stream-openai-completions.js';
|
|
12
|
+
import { streamAnthropic } from './stream-anthropic.js';
|
|
11
13
|
|
|
12
14
|
export function streamProvider(flavor: PiApiFlavor, req: PiStreamRequest): AsyncIterable<PiStreamEvent> {
|
|
13
15
|
switch (flavor) {
|
|
14
16
|
case 'google-gemini':
|
|
15
17
|
return streamGoogle(req);
|
|
16
18
|
case 'openai-completions':
|
|
17
|
-
|
|
19
|
+
return streamOpenAICompletions(req);
|
|
18
20
|
case 'anthropic-messages':
|
|
19
|
-
|
|
21
|
+
return streamAnthropic(req);
|
|
20
22
|
}
|
|
21
23
|
}
|