@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 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/LICENSE +21 -0
- package/package.json +51 -4
- package/src/core/catalog.ts +400 -0
- package/src/core/connection.ts +119 -0
- package/src/core/costs.ts +477 -0
- package/src/core/index.ts +186 -0
- package/src/core/oauth.ts +279 -0
- package/src/core/streams.ts +89 -0
- package/src/core/usage.ts +813 -0
- package/src/extension/completions.ts +20 -0
- package/src/extension/config.ts +314 -0
- package/src/extension/fast.ts +81 -0
- package/src/extension/files.ts +146 -0
- package/src/extension/footer.ts +229 -0
- package/src/extension/index.ts +477 -0
- package/src/extension/oauth.ts +203 -0
- package/src/extension/pause.ts +126 -0
- package/src/extension/prices.ts +191 -0
- package/src/extension/prompt-cache.ts +17 -0
- package/src/extension/provider.ts +386 -0
- package/src/extension/retry.ts +41 -0
- package/src/extension/status.ts +166 -0
- package/src/extension/tps.ts +215 -0
- package/src/extension/usage.ts +609 -0
- package/src/extension/web-search.ts +307 -0
- package/src/web-search/endpoint.ts +21 -0
- package/src/web-search/index.ts +21 -0
- package/src/web-search/model-selector.ts +93 -0
- package/src/web-search/search.ts +571 -0
- package/src/web-search/sse.ts +144 -0
- package/README.md +0 -3
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
import { calculateCost } from "@earendil-works/pi-ai";
|
|
2
|
+
import type { Api, Model, Usage } from "@earendil-works/pi-ai";
|
|
3
|
+
|
|
4
|
+
import { resolveResponsesEndpoint } from "./endpoint.ts";
|
|
5
|
+
import { consumeSseStream } from "./sse.ts";
|
|
6
|
+
import type { SseEvent } from "./sse.ts";
|
|
7
|
+
|
|
8
|
+
export interface WebSearchSource {
|
|
9
|
+
url: string;
|
|
10
|
+
title?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type WebSearchProgressPhase =
|
|
14
|
+
| "starting"
|
|
15
|
+
| "searching"
|
|
16
|
+
| "analyzing"
|
|
17
|
+
| "generating"
|
|
18
|
+
| "done";
|
|
19
|
+
|
|
20
|
+
export interface WebSearchProgressEvent {
|
|
21
|
+
type: WebSearchProgressPhase;
|
|
22
|
+
phase: WebSearchProgressPhase;
|
|
23
|
+
message: string;
|
|
24
|
+
model: string;
|
|
25
|
+
query: string;
|
|
26
|
+
searchQuery?: string;
|
|
27
|
+
chars?: number;
|
|
28
|
+
sourcesCount?: number;
|
|
29
|
+
durationMs?: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RunWebSearchOptions {
|
|
33
|
+
/** CLIProxyAPI root, `/v1` base URL, or full `/v1/responses` URL. */
|
|
34
|
+
responsesUrl: string;
|
|
35
|
+
apiKey?: string;
|
|
36
|
+
/** A model id, or a pi-ai Model when usage cost should be priced. */
|
|
37
|
+
model: string | Model<Api>;
|
|
38
|
+
query: string;
|
|
39
|
+
signal?: AbortSignal;
|
|
40
|
+
onProgress?: (event: WebSearchProgressEvent) => void;
|
|
41
|
+
referenceDate?: Date | string;
|
|
42
|
+
fetchFn?: typeof fetch;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface WebSearchResult {
|
|
46
|
+
answer: string;
|
|
47
|
+
sources: WebSearchSource[];
|
|
48
|
+
usage?: Usage;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface ResponsesSourcePayload {
|
|
52
|
+
title?: string;
|
|
53
|
+
url?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface ResponsesContentPayload {
|
|
57
|
+
annotations?: ResponsesSourcePayload[];
|
|
58
|
+
text?: string;
|
|
59
|
+
type?: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ResponsesItemPayload {
|
|
63
|
+
action?: {
|
|
64
|
+
query?: string;
|
|
65
|
+
sources?: ResponsesSourcePayload[];
|
|
66
|
+
};
|
|
67
|
+
content?: ResponsesContentPayload[];
|
|
68
|
+
type?: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface ResponsesUsagePayload {
|
|
72
|
+
input_tokens?: number;
|
|
73
|
+
input_tokens_details?: {
|
|
74
|
+
cache_write_tokens?: number;
|
|
75
|
+
cached_tokens?: number;
|
|
76
|
+
};
|
|
77
|
+
output_tokens?: number;
|
|
78
|
+
output_tokens_details?: {
|
|
79
|
+
reasoning_tokens?: number;
|
|
80
|
+
};
|
|
81
|
+
total_tokens?: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface ResponsesPayload {
|
|
85
|
+
error?: {
|
|
86
|
+
message?: string;
|
|
87
|
+
};
|
|
88
|
+
message?: string;
|
|
89
|
+
output?: ResponsesItemPayload[];
|
|
90
|
+
usage?: ResponsesUsagePayload;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface ResponsesEventPayload extends ResponsesPayload {
|
|
94
|
+
delta?: string;
|
|
95
|
+
error?: {
|
|
96
|
+
message?: string;
|
|
97
|
+
};
|
|
98
|
+
item?: ResponsesItemPayload;
|
|
99
|
+
message?: string;
|
|
100
|
+
response?: ResponsesPayload;
|
|
101
|
+
text?: string;
|
|
102
|
+
type?: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface SearchStreamState {
|
|
106
|
+
completedMessageText: string;
|
|
107
|
+
lastDeltaUpdateTime: number;
|
|
108
|
+
lastPhase: string;
|
|
109
|
+
rawUsage?: ResponsesUsagePayload;
|
|
110
|
+
sources: Map<string, string>;
|
|
111
|
+
streamedMessageText: string;
|
|
112
|
+
streamedText: string;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
type ProgressEmitter = (
|
|
116
|
+
phase: WebSearchProgressPhase,
|
|
117
|
+
message: string,
|
|
118
|
+
extra?: Partial<WebSearchProgressEvent>
|
|
119
|
+
) => void;
|
|
120
|
+
|
|
121
|
+
export const SEARCH_INSTRUCTIONS =
|
|
122
|
+
"You are a web search assistant. Search the web to find accurate, up-to-date information for the user's query. Provide a comprehensive summary with all relevant details, numbers, dates, and sources.";
|
|
123
|
+
|
|
124
|
+
export const buildSearchInstructions = (
|
|
125
|
+
referenceDate: Date | string = new Date()
|
|
126
|
+
): string => {
|
|
127
|
+
const date =
|
|
128
|
+
referenceDate instanceof Date ? referenceDate : new Date(referenceDate);
|
|
129
|
+
|
|
130
|
+
const dateStr = Number.isNaN(date.getTime())
|
|
131
|
+
? String(referenceDate).slice(0, 10)
|
|
132
|
+
: date.toISOString().slice(0, 10);
|
|
133
|
+
|
|
134
|
+
return `${SEARCH_INSTRUCTIONS}\nCurrent reference date: ${dateStr}. Use this reference date to ground relative time expressions (such as today, yesterday, recent, latest, or this year) and ensure search queries and results remain up to date.`;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const extractErrorMessage = (
|
|
138
|
+
payload: ResponsesEventPayload,
|
|
139
|
+
defaultMessage: string
|
|
140
|
+
): string => {
|
|
141
|
+
if (payload.error?.message?.trim()) {
|
|
142
|
+
return payload.error.message.trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (payload.message?.trim()) {
|
|
146
|
+
return payload.message.trim();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return defaultMessage;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const isTokenCount = (value: number | undefined): value is number =>
|
|
153
|
+
value !== undefined && Number.isSafeInteger(value) && value >= 0;
|
|
154
|
+
|
|
155
|
+
const toUsage = (
|
|
156
|
+
raw: ResponsesUsagePayload | undefined,
|
|
157
|
+
model: Model<Api> | undefined
|
|
158
|
+
): Usage | undefined => {
|
|
159
|
+
if (!raw) {
|
|
160
|
+
return undefined;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const input = raw.input_tokens;
|
|
164
|
+
const output = raw.output_tokens;
|
|
165
|
+
const cacheRead = raw.input_tokens_details?.cached_tokens ?? 0;
|
|
166
|
+
const cacheWrite = raw.input_tokens_details?.cache_write_tokens ?? 0;
|
|
167
|
+
const reasoning = raw.output_tokens_details?.reasoning_tokens ?? 0;
|
|
168
|
+
|
|
169
|
+
const totalTokens =
|
|
170
|
+
raw.total_tokens ??
|
|
171
|
+
(isTokenCount(input) && isTokenCount(output) ? input + output : undefined);
|
|
172
|
+
|
|
173
|
+
if (
|
|
174
|
+
!isTokenCount(input) ||
|
|
175
|
+
!isTokenCount(output) ||
|
|
176
|
+
!isTokenCount(cacheRead) ||
|
|
177
|
+
!isTokenCount(cacheWrite) ||
|
|
178
|
+
!isTokenCount(reasoning) ||
|
|
179
|
+
!isTokenCount(totalTokens)
|
|
180
|
+
) {
|
|
181
|
+
return undefined;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const usage: Usage = {
|
|
185
|
+
cacheRead,
|
|
186
|
+
cacheWrite,
|
|
187
|
+
cost: { cacheRead: 0, cacheWrite: 0, input: 0, output: 0, total: 0 },
|
|
188
|
+
input: Math.max(0, input - cacheRead - cacheWrite),
|
|
189
|
+
output,
|
|
190
|
+
reasoning,
|
|
191
|
+
totalTokens,
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
if (model) {
|
|
195
|
+
calculateCost(model, usage);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return usage;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
const addSource = (
|
|
202
|
+
sources: Map<string, string>,
|
|
203
|
+
urlValue: string | undefined,
|
|
204
|
+
titleValue: string | undefined
|
|
205
|
+
): void => {
|
|
206
|
+
if (!urlValue?.trim()) {
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const url = urlValue.trim();
|
|
211
|
+
const title = titleValue?.trim() ?? "";
|
|
212
|
+
|
|
213
|
+
if (!sources.has(url) || (!sources.get(url) && title)) {
|
|
214
|
+
sources.set(url, title);
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
const extractSourcesFromItem = (
|
|
219
|
+
item: ResponsesItemPayload,
|
|
220
|
+
sources: Map<string, string>
|
|
221
|
+
): void => {
|
|
222
|
+
if (item.type === "web_search_call") {
|
|
223
|
+
for (const source of item.action?.sources ?? []) {
|
|
224
|
+
addSource(sources, source.url, source.title);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (item.type === "message") {
|
|
229
|
+
for (const part of item.content ?? []) {
|
|
230
|
+
for (const annotation of part.annotations ?? []) {
|
|
231
|
+
addSource(sources, annotation.url, annotation.title);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const messageTextFromItem = (item: ResponsesItemPayload): string => {
|
|
238
|
+
if (item.type !== "message") {
|
|
239
|
+
return "";
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const parts: string[] = [];
|
|
243
|
+
|
|
244
|
+
for (const part of item.content ?? []) {
|
|
245
|
+
if ((part.type === "output_text" || part.type === "text") && part.text) {
|
|
246
|
+
parts.push(part.text);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return parts.join("");
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const lastMessageText = (output: ResponsesItemPayload[]): string => {
|
|
254
|
+
let text = "";
|
|
255
|
+
|
|
256
|
+
for (const item of output) {
|
|
257
|
+
const candidate = messageTextFromItem(item);
|
|
258
|
+
|
|
259
|
+
if (candidate.trim()) {
|
|
260
|
+
text = candidate;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
return text;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const handleLifecycleEvent = (
|
|
268
|
+
eventType: string,
|
|
269
|
+
state: SearchStreamState,
|
|
270
|
+
emit: ProgressEmitter,
|
|
271
|
+
query: string
|
|
272
|
+
): boolean => {
|
|
273
|
+
if (
|
|
274
|
+
eventType === "response.web_search_call.searching" ||
|
|
275
|
+
eventType === "response.web_search_call.in_progress"
|
|
276
|
+
) {
|
|
277
|
+
if (state.lastPhase !== "searching") {
|
|
278
|
+
state.lastPhase = "searching";
|
|
279
|
+
emit("searching", `Searching the web for ${query}...`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return true;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (eventType === "response.web_search_call.completed") {
|
|
286
|
+
if (state.lastPhase !== "analyzing") {
|
|
287
|
+
state.lastPhase = "analyzing";
|
|
288
|
+
emit("analyzing", "Analyzing search results...");
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return false;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
const handleTextEvent = (
|
|
298
|
+
eventType: string,
|
|
299
|
+
payload: ResponsesEventPayload,
|
|
300
|
+
state: SearchStreamState,
|
|
301
|
+
emit: ProgressEmitter
|
|
302
|
+
): boolean => {
|
|
303
|
+
if (eventType === "response.output_text.delta") {
|
|
304
|
+
if (payload.delta) {
|
|
305
|
+
state.streamedText += payload.delta;
|
|
306
|
+
const now = Date.now();
|
|
307
|
+
|
|
308
|
+
if (now - state.lastDeltaUpdateTime >= 100) {
|
|
309
|
+
state.lastDeltaUpdateTime = now;
|
|
310
|
+
emit(
|
|
311
|
+
"generating",
|
|
312
|
+
`Summarizing results (${state.streamedText.length} chars)...`,
|
|
313
|
+
{
|
|
314
|
+
chars: state.streamedText.length,
|
|
315
|
+
}
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
return true;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (eventType === "response.output_text.done") {
|
|
324
|
+
if (payload.text && !state.streamedText) {
|
|
325
|
+
state.streamedText = payload.text;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return true;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return false;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const handleItemEvent = (
|
|
335
|
+
eventType: string,
|
|
336
|
+
payload: ResponsesEventPayload,
|
|
337
|
+
state: SearchStreamState,
|
|
338
|
+
emit: ProgressEmitter
|
|
339
|
+
): boolean => {
|
|
340
|
+
if (
|
|
341
|
+
eventType !== "response.output_item.done" &&
|
|
342
|
+
eventType !== "response.output_item.added"
|
|
343
|
+
) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!payload.item) {
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
extractSourcesFromItem(payload.item, state.sources);
|
|
352
|
+
|
|
353
|
+
const searchQuery = payload.item.action?.query;
|
|
354
|
+
|
|
355
|
+
if (
|
|
356
|
+
payload.item.type === "web_search_call" &&
|
|
357
|
+
searchQuery &&
|
|
358
|
+
state.lastPhase !== "searching"
|
|
359
|
+
) {
|
|
360
|
+
state.lastPhase = "searching";
|
|
361
|
+
emit("searching", `Searching the web for ${searchQuery}...`, {
|
|
362
|
+
searchQuery,
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const text = messageTextFromItem(payload.item);
|
|
367
|
+
|
|
368
|
+
if (text.trim()) {
|
|
369
|
+
state.streamedMessageText = text;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return true;
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
const handleCompletedEvent = (
|
|
376
|
+
eventType: string,
|
|
377
|
+
payload: ResponsesEventPayload,
|
|
378
|
+
state: SearchStreamState
|
|
379
|
+
): boolean => {
|
|
380
|
+
if (eventType !== "response.completed") {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const completed = payload.response ?? payload;
|
|
385
|
+
state.rawUsage = completed.usage;
|
|
386
|
+
|
|
387
|
+
for (const item of completed.output ?? []) {
|
|
388
|
+
extractSourcesFromItem(item, state.sources);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
if (completed.output) {
|
|
392
|
+
// Fact 2: Kimi and Muse put the answer in a later message item.
|
|
393
|
+
state.completedMessageText = lastMessageText(completed.output);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
return true;
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
const handleEvent = (
|
|
400
|
+
{ event, data }: SseEvent,
|
|
401
|
+
state: SearchStreamState,
|
|
402
|
+
emit: ProgressEmitter,
|
|
403
|
+
query: string
|
|
404
|
+
): void => {
|
|
405
|
+
// SAFETY: SSE JSON uses the Responses wire shape. Every payload field is
|
|
406
|
+
// optional in the local payload types and is checked before it is used.
|
|
407
|
+
const payload = data as ResponsesEventPayload;
|
|
408
|
+
const eventType = payload.type ?? event;
|
|
409
|
+
|
|
410
|
+
if (eventType === "response.failed" || eventType === "error") {
|
|
411
|
+
throw new Error(extractErrorMessage(payload, "Web search response failed"));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (handleLifecycleEvent(eventType, state, emit, query)) {
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (handleTextEvent(eventType, payload, state, emit)) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
if (handleItemEvent(eventType, payload, state, emit)) {
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
handleCompletedEvent(eventType, payload, state);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
export const runWebSearch = async (
|
|
430
|
+
options: RunWebSearchOptions
|
|
431
|
+
): Promise<WebSearchResult> => {
|
|
432
|
+
const {
|
|
433
|
+
responsesUrl,
|
|
434
|
+
apiKey,
|
|
435
|
+
model,
|
|
436
|
+
query,
|
|
437
|
+
signal,
|
|
438
|
+
onProgress,
|
|
439
|
+
referenceDate = new Date(),
|
|
440
|
+
fetchFn = fetch,
|
|
441
|
+
} = options;
|
|
442
|
+
|
|
443
|
+
const trimmedQuery = query.trim();
|
|
444
|
+
|
|
445
|
+
if (!trimmedQuery) {
|
|
446
|
+
throw new Error("web_search requires a non-empty query");
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- The typed union is a model id string or a pi-ai Model, and typeof is the intended way to tell them apart.
|
|
450
|
+
const modelObject = typeof model === "object" ? model : undefined;
|
|
451
|
+
|
|
452
|
+
const modelId = modelObject ? modelObject.id : String(model).trim();
|
|
453
|
+
|
|
454
|
+
if (!modelId) {
|
|
455
|
+
throw new Error("web_search requires a model");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
signal?.throwIfAborted();
|
|
459
|
+
|
|
460
|
+
const startTime = Date.now();
|
|
461
|
+
|
|
462
|
+
const emit: ProgressEmitter = (phase, message, extra = {}) => {
|
|
463
|
+
onProgress?.({
|
|
464
|
+
message,
|
|
465
|
+
model: modelId,
|
|
466
|
+
phase,
|
|
467
|
+
query: trimmedQuery,
|
|
468
|
+
type: phase,
|
|
469
|
+
...extra,
|
|
470
|
+
});
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
emit("starting", `Starting web search (${modelId})...`);
|
|
474
|
+
|
|
475
|
+
const baseHeaders = {
|
|
476
|
+
accept: "text/event-stream",
|
|
477
|
+
"content-type": "application/json",
|
|
478
|
+
};
|
|
479
|
+
|
|
480
|
+
const headers = apiKey
|
|
481
|
+
? { ...baseHeaders, authorization: `Bearer ${apiKey}` }
|
|
482
|
+
: baseHeaders;
|
|
483
|
+
|
|
484
|
+
const response = await fetchFn(resolveResponsesEndpoint(responsesUrl), {
|
|
485
|
+
body: JSON.stringify({
|
|
486
|
+
input: [
|
|
487
|
+
{
|
|
488
|
+
content: [{ text: trimmedQuery, type: "input_text" }],
|
|
489
|
+
role: "user",
|
|
490
|
+
},
|
|
491
|
+
],
|
|
492
|
+
instructions: buildSearchInstructions(referenceDate),
|
|
493
|
+
model: modelId,
|
|
494
|
+
store: false,
|
|
495
|
+
stream: true,
|
|
496
|
+
tools: [{ type: "web_search" }],
|
|
497
|
+
}),
|
|
498
|
+
headers,
|
|
499
|
+
method: "POST",
|
|
500
|
+
signal,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
if (!response.ok) {
|
|
504
|
+
let errorText = `HTTP ${response.status}`;
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const parsed: ResponsesEventPayload = JSON.parse(await response.text());
|
|
508
|
+
errorText = extractErrorMessage(parsed, errorText);
|
|
509
|
+
} catch {
|
|
510
|
+
// Keep the HTTP status fallback when the error body is not JSON.
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
throw new Error(`Web search failed: ${errorText}`);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (!response.body) {
|
|
517
|
+
throw new Error("Web search streaming response has no body");
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const state: SearchStreamState = {
|
|
521
|
+
completedMessageText: "",
|
|
522
|
+
lastDeltaUpdateTime: 0,
|
|
523
|
+
lastPhase: "",
|
|
524
|
+
sources: new Map<string, string>(),
|
|
525
|
+
streamedMessageText: "",
|
|
526
|
+
streamedText: "",
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
await consumeSseStream(
|
|
530
|
+
response.body,
|
|
531
|
+
(event) => handleEvent(event, state, emit, trimmedQuery),
|
|
532
|
+
signal
|
|
533
|
+
);
|
|
534
|
+
signal?.throwIfAborted();
|
|
535
|
+
|
|
536
|
+
const answer = (
|
|
537
|
+
state.completedMessageText ||
|
|
538
|
+
state.streamedMessageText ||
|
|
539
|
+
state.streamedText
|
|
540
|
+
).trim();
|
|
541
|
+
|
|
542
|
+
if (!answer) {
|
|
543
|
+
throw new Error("Web search completed without any search result text");
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const usage = toUsage(state.rawUsage, modelObject);
|
|
547
|
+
const sourceList: WebSearchSource[] = [];
|
|
548
|
+
|
|
549
|
+
for (const [url, title] of state.sources) {
|
|
550
|
+
const source: WebSearchSource = { url };
|
|
551
|
+
|
|
552
|
+
if (title) {
|
|
553
|
+
source.title = title;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
sourceList.push(source);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
emit("done", "Web search complete.", {
|
|
560
|
+
durationMs: Date.now() - startTime,
|
|
561
|
+
sourcesCount: sourceList.length,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const result: WebSearchResult = { answer, sources: sourceList };
|
|
565
|
+
|
|
566
|
+
if (usage) {
|
|
567
|
+
result.usage = usage;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return result;
|
|
571
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
export interface SseEvent {
|
|
2
|
+
event: string;
|
|
3
|
+
data: unknown;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export interface SseParser {
|
|
7
|
+
push: (chunk: string) => void;
|
|
8
|
+
end: () => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export const createSseParser = (
|
|
12
|
+
onEvent: (event: SseEvent) => void
|
|
13
|
+
): SseParser => {
|
|
14
|
+
let buffer = "";
|
|
15
|
+
let eventName = "";
|
|
16
|
+
let dataLines: string[] = [];
|
|
17
|
+
|
|
18
|
+
const dispatch = () => {
|
|
19
|
+
if (dataLines.length === 0) {
|
|
20
|
+
eventName = "";
|
|
21
|
+
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const text = dataLines.join("\n");
|
|
26
|
+
let data: unknown = text;
|
|
27
|
+
|
|
28
|
+
if (text !== "[DONE]") {
|
|
29
|
+
try {
|
|
30
|
+
data = JSON.parse(text);
|
|
31
|
+
} catch {
|
|
32
|
+
data = text;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
onEvent({ data, event: eventName || "message" });
|
|
37
|
+
eventName = "";
|
|
38
|
+
dataLines = [];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const processLine = (line: string) => {
|
|
42
|
+
if (line === "") {
|
|
43
|
+
dispatch();
|
|
44
|
+
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (line.startsWith(":")) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const separator = line.indexOf(":");
|
|
53
|
+
const field = separator === -1 ? line : line.slice(0, separator);
|
|
54
|
+
let value = separator === -1 ? "" : line.slice(separator + 1);
|
|
55
|
+
|
|
56
|
+
if (value.startsWith(" ")) {
|
|
57
|
+
value = value.slice(1);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
if (field === "event") {
|
|
61
|
+
eventName = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (field === "data") {
|
|
65
|
+
dataLines.push(value);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
end: () => {
|
|
71
|
+
if (buffer.length > 0) {
|
|
72
|
+
let line = buffer;
|
|
73
|
+
buffer = "";
|
|
74
|
+
|
|
75
|
+
if (line.endsWith("\r")) {
|
|
76
|
+
line = line.slice(0, -1);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
processLine(line);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
dispatch();
|
|
83
|
+
},
|
|
84
|
+
push: (chunk: string) => {
|
|
85
|
+
buffer += chunk;
|
|
86
|
+
let newline = buffer.indexOf("\n");
|
|
87
|
+
|
|
88
|
+
while (newline !== -1) {
|
|
89
|
+
let line = buffer.slice(0, newline);
|
|
90
|
+
buffer = buffer.slice(newline + 1);
|
|
91
|
+
|
|
92
|
+
if (line.endsWith("\r")) {
|
|
93
|
+
line = line.slice(0, -1);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
processLine(line);
|
|
97
|
+
newline = buffer.indexOf("\n");
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
};
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const consumeSseStream = async (
|
|
104
|
+
stream: ReadableStream<Uint8Array>,
|
|
105
|
+
onEvent: (event: SseEvent) => void,
|
|
106
|
+
signal?: AbortSignal
|
|
107
|
+
): Promise<void> => {
|
|
108
|
+
const reader = stream.getReader();
|
|
109
|
+
const decoder = new TextDecoder();
|
|
110
|
+
const parser = createSseParser(onEvent);
|
|
111
|
+
|
|
112
|
+
const onAbort = () => {
|
|
113
|
+
void reader.cancel().catch(() => null);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
signal?.throwIfAborted();
|
|
117
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
118
|
+
|
|
119
|
+
const readNext = async (): Promise<void> => {
|
|
120
|
+
signal?.throwIfAborted();
|
|
121
|
+
|
|
122
|
+
const { done, value } = await reader.read();
|
|
123
|
+
|
|
124
|
+
if (done) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (value) {
|
|
129
|
+
parser.push(decoder.decode(value, { stream: true }));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
await readNext();
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
await readNext();
|
|
137
|
+
signal?.throwIfAborted();
|
|
138
|
+
parser.push(decoder.decode());
|
|
139
|
+
parser.end();
|
|
140
|
+
} finally {
|
|
141
|
+
signal?.removeEventListener("abort", onAbort);
|
|
142
|
+
reader.releaseLock();
|
|
143
|
+
}
|
|
144
|
+
};
|
package/README.md
DELETED