@juvio15/pi-cliproxyapi-provider 0.0.0-stage → 0.1.1

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,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
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.