@normful/parallel-web-pi-extension 0.0.0-stage → 1.0.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 +23 -0
- package/README.md +231 -2
- package/package.json +63 -4
- package/src/index.ts +362 -0
- package/src/oauth/index.ts +256 -0
- package/src/parallel-auth.ts +116 -0
- package/src/parallel-client.ts +84 -0
- package/src/parallel-responses.ts +282 -0
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
2
|
+
|
|
3
|
+
export const PARALLEL_RESPONSES_URL = 'https://api.parallel.ai/v1/responses';
|
|
4
|
+
export const PARALLEL_RESPONSES_MAX_INPUT_CHARS = 20_000;
|
|
5
|
+
export const PARALLEL_RESPONSES_TIMEOUT_MS = 120_000;
|
|
6
|
+
export const DEFAULT_RESEARCH_EFFORT = 'medium';
|
|
7
|
+
export const MAX_RESPONSE_ID_LENGTH = 512;
|
|
8
|
+
|
|
9
|
+
export type ResearchEffort = 'low' | 'medium' | 'high';
|
|
10
|
+
|
|
11
|
+
export interface ResearchInput {
|
|
12
|
+
query: string;
|
|
13
|
+
effort?: ResearchEffort;
|
|
14
|
+
previous_response_id?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
// Only these instructions, the explicit question and optional continuation ID
|
|
18
|
+
// cross the research boundary.
|
|
19
|
+
// Do not assemble this prompt from Pi's session, system prompt, or local files.
|
|
20
|
+
const RESEARCH_INSTRUCTIONS =
|
|
21
|
+
"Research the user's question using current web sources. Return a direct, evidence-based answer with citations. State uncertainty when the sources do not support a conclusion.";
|
|
22
|
+
|
|
23
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
24
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// IDs are opaque. Bound their size and keep the displayed continuation header
|
|
28
|
+
// on one line without coupling the tool to the server's current ID format.
|
|
29
|
+
function isResponseId(value: unknown): value is string {
|
|
30
|
+
return (
|
|
31
|
+
typeof value === 'string' &&
|
|
32
|
+
value.length > 0 &&
|
|
33
|
+
value.length <= MAX_RESPONSE_ID_LENGTH &&
|
|
34
|
+
!/[\s\p{Cc}]/u.test(value)
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function renderResearch(payload: unknown): string {
|
|
39
|
+
if (!isRecord(payload) || payload.status !== 'completed') {
|
|
40
|
+
throw new Error('Parallel returned a response that was not completed.');
|
|
41
|
+
}
|
|
42
|
+
if (!Array.isArray(payload.output)) {
|
|
43
|
+
throw new Error('Parallel returned a response without output messages.');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const texts: string[] = [];
|
|
47
|
+
const sources = new Map<string, { title: string; passages: Set<string> }>();
|
|
48
|
+
for (const item of payload.output) {
|
|
49
|
+
if (!isRecord(item) || typeof item.type !== 'string') {
|
|
50
|
+
throw new Error('Parallel returned malformed research output.');
|
|
51
|
+
}
|
|
52
|
+
if (item.type !== 'message') continue;
|
|
53
|
+
if (item.status !== undefined && item.status !== 'completed') {
|
|
54
|
+
throw new Error('Parallel returned an answer that was not completed.');
|
|
55
|
+
}
|
|
56
|
+
if (!Array.isArray(item.content)) {
|
|
57
|
+
throw new Error('Parallel returned malformed research output.');
|
|
58
|
+
}
|
|
59
|
+
for (const content of item.content) {
|
|
60
|
+
if (!isRecord(content) || typeof content.type !== 'string') {
|
|
61
|
+
throw new Error('Parallel returned malformed research output.');
|
|
62
|
+
}
|
|
63
|
+
if (content.type !== 'output_text') continue;
|
|
64
|
+
if (typeof content.text !== 'string') {
|
|
65
|
+
throw new Error('Parallel returned malformed research output.');
|
|
66
|
+
}
|
|
67
|
+
texts.push(content.text);
|
|
68
|
+
if (!Array.isArray(content.annotations)) continue;
|
|
69
|
+
// API citation offsets count Unicode code points, not UTF-16 units.
|
|
70
|
+
const characters = [...content.text];
|
|
71
|
+
for (const citation of content.annotations) {
|
|
72
|
+
if (
|
|
73
|
+
!isRecord(citation) ||
|
|
74
|
+
citation.type !== 'url_citation' ||
|
|
75
|
+
typeof citation.url !== 'string'
|
|
76
|
+
) {
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
let url: URL;
|
|
80
|
+
try {
|
|
81
|
+
url = new URL(citation.url);
|
|
82
|
+
} catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') continue;
|
|
86
|
+
const href = url.toString();
|
|
87
|
+
const title =
|
|
88
|
+
typeof citation.title === 'string' ? citation.title.trim() : '';
|
|
89
|
+
let source = sources.get(href);
|
|
90
|
+
if (!source) {
|
|
91
|
+
source = { title: title || href, passages: new Set() };
|
|
92
|
+
sources.set(href, source);
|
|
93
|
+
}
|
|
94
|
+
const start = citation.start_index;
|
|
95
|
+
const end = citation.end_index;
|
|
96
|
+
if (
|
|
97
|
+
typeof start === 'number' &&
|
|
98
|
+
typeof end === 'number' &&
|
|
99
|
+
Number.isSafeInteger(start) &&
|
|
100
|
+
Number.isSafeInteger(end) &&
|
|
101
|
+
start >= 0 &&
|
|
102
|
+
end > start &&
|
|
103
|
+
end <= characters.length
|
|
104
|
+
) {
|
|
105
|
+
const passage = characters.slice(start, end).join('');
|
|
106
|
+
source.passages.add(
|
|
107
|
+
`Cited answer passage (part ${texts.length}, characters ${start}:${end}): ${JSON.stringify(passage)}`
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const text = texts.join('\n\n');
|
|
115
|
+
if (!text.trim())
|
|
116
|
+
throw new Error('Parallel returned an empty research response.');
|
|
117
|
+
if (sources.size === 0) return text;
|
|
118
|
+
|
|
119
|
+
const list = [...sources].map(([url, { title, passages }], index) => {
|
|
120
|
+
const label = title
|
|
121
|
+
.replaceAll('\\', '\\\\')
|
|
122
|
+
.replaceAll('[', '\\[')
|
|
123
|
+
.replaceAll(']', '\\]')
|
|
124
|
+
.replace(/[\r\n]+/g, ' ');
|
|
125
|
+
const href = url
|
|
126
|
+
.replaceAll('\\', '%5C')
|
|
127
|
+
.replaceAll('<', '%3C')
|
|
128
|
+
.replaceAll('>', '%3E');
|
|
129
|
+
const link = `${index + 1}. [${label}](<${href}>)`;
|
|
130
|
+
return [link, ...[...passages].map((passage) => ` ${passage}`)].join(
|
|
131
|
+
'\n'
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
const rangeNote = [...sources.values()].some((source) => source.passages.size)
|
|
135
|
+
? 'Passage locations use zero-based Unicode character offsets within each original text part; the end is exclusive.\n'
|
|
136
|
+
: '';
|
|
137
|
+
return `${text}\n\nSources:\n${rangeNote}${list.join('\n')}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function safeError(error: unknown, apiKey: string): Error {
|
|
141
|
+
let message: string;
|
|
142
|
+
try {
|
|
143
|
+
message = error instanceof Error ? error.message : String(error);
|
|
144
|
+
} catch {
|
|
145
|
+
message = 'Unknown research failure';
|
|
146
|
+
}
|
|
147
|
+
if (apiKey) message = message.replaceAll(apiKey, '[REDACTED]');
|
|
148
|
+
const result = new Error(
|
|
149
|
+
(message.trim() || 'Unknown research failure').slice(0, 1_000)
|
|
150
|
+
);
|
|
151
|
+
// Preserve HTTP status for the extension's existing authentication guidance.
|
|
152
|
+
if (isRecord(error) && typeof error.status === 'number') {
|
|
153
|
+
Object.assign(result, { status: error.status });
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function parseResearchInput(
|
|
159
|
+
input: unknown
|
|
160
|
+
): ResearchInput & { effort: ResearchEffort } {
|
|
161
|
+
if (
|
|
162
|
+
!isRecord(input) ||
|
|
163
|
+
typeof input.query !== 'string' ||
|
|
164
|
+
!input.query.trim()
|
|
165
|
+
) {
|
|
166
|
+
throw new Error('Parallel Research requires a non-empty question.');
|
|
167
|
+
}
|
|
168
|
+
const effort =
|
|
169
|
+
input.effort === undefined ? DEFAULT_RESEARCH_EFFORT : input.effort;
|
|
170
|
+
if (effort !== 'low' && effort !== 'medium' && effort !== 'high') {
|
|
171
|
+
throw new Error('Parallel Research effort must be low, medium, or high.');
|
|
172
|
+
}
|
|
173
|
+
if (
|
|
174
|
+
[...`${RESEARCH_INSTRUCTIONS}\n${input.query}`].length >
|
|
175
|
+
PARALLEL_RESPONSES_MAX_INPUT_CHARS
|
|
176
|
+
) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
'Parallel Research exceeds the 20,000-character input limit, including research instructions.'
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
const previousResponseId = input.previous_response_id;
|
|
182
|
+
if (previousResponseId !== undefined && !isResponseId(previousResponseId)) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`Parallel Research previous_response_id must be a non-empty string of at most ${MAX_RESPONSE_ID_LENGTH} characters without whitespace or control characters.`
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
query: input.query,
|
|
189
|
+
effort,
|
|
190
|
+
...(previousResponseId !== undefined
|
|
191
|
+
? { previous_response_id: previousResponseId }
|
|
192
|
+
: {}),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function runParallelResearch(
|
|
197
|
+
apiKey: string,
|
|
198
|
+
input: ResearchInput,
|
|
199
|
+
signal?: AbortSignal
|
|
200
|
+
): Promise<{ text: string; effort: ResearchEffort; responseId?: string }> {
|
|
201
|
+
const { query, effort, previous_response_id } = parseResearchInput(input);
|
|
202
|
+
if (!apiKey) {
|
|
203
|
+
throw new Error(
|
|
204
|
+
'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.'
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const deadline = new AbortController();
|
|
209
|
+
const requestSignal = signal
|
|
210
|
+
? AbortSignal.any([signal, deadline.signal])
|
|
211
|
+
: deadline.signal;
|
|
212
|
+
// Keep the deadline active through body consumption, not just response headers.
|
|
213
|
+
const timeout = setTimeout(
|
|
214
|
+
() => deadline.abort(),
|
|
215
|
+
PARALLEL_RESPONSES_TIMEOUT_MS
|
|
216
|
+
);
|
|
217
|
+
timeout.unref?.();
|
|
218
|
+
try {
|
|
219
|
+
requestSignal.throwIfAborted();
|
|
220
|
+
const response = await fetch(PARALLEL_RESPONSES_URL, {
|
|
221
|
+
method: 'POST',
|
|
222
|
+
headers: {
|
|
223
|
+
Authorization: `Bearer ${apiKey}`,
|
|
224
|
+
'Content-Type': 'application/json',
|
|
225
|
+
'X-Tool-Calling-Package': `npm:@normful/parallel-web-pi-extension/v${pkg.version}`,
|
|
226
|
+
},
|
|
227
|
+
body: JSON.stringify({
|
|
228
|
+
model: 'parallel',
|
|
229
|
+
input: query,
|
|
230
|
+
instructions: RESEARCH_INSTRUCTIONS,
|
|
231
|
+
reasoning: { effort },
|
|
232
|
+
stream: false,
|
|
233
|
+
previous_response_id,
|
|
234
|
+
}),
|
|
235
|
+
signal: requestSignal,
|
|
236
|
+
redirect: 'error',
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
if (!response.ok) {
|
|
240
|
+
let message = response.statusText || 'request failed';
|
|
241
|
+
try {
|
|
242
|
+
const payload: unknown = await response.json();
|
|
243
|
+
if (
|
|
244
|
+
isRecord(payload) &&
|
|
245
|
+
isRecord(payload.error) &&
|
|
246
|
+
typeof payload.error.message === 'string'
|
|
247
|
+
) {
|
|
248
|
+
message = payload.error.message;
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// A non-JSON error still has a useful HTTP status.
|
|
252
|
+
}
|
|
253
|
+
requestSignal.throwIfAborted();
|
|
254
|
+
throw Object.assign(
|
|
255
|
+
new Error(
|
|
256
|
+
`Parallel Responses request failed (${response.status}): ${message}`
|
|
257
|
+
),
|
|
258
|
+
{ status: response.status }
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const payload: unknown = await response.json();
|
|
263
|
+
requestSignal.throwIfAborted();
|
|
264
|
+
return {
|
|
265
|
+
text: renderResearch(payload),
|
|
266
|
+
effort,
|
|
267
|
+
...(isRecord(payload) && isResponseId(payload.id)
|
|
268
|
+
? { responseId: payload.id }
|
|
269
|
+
: {}),
|
|
270
|
+
};
|
|
271
|
+
} catch (error) {
|
|
272
|
+
if (signal?.aborted) throw new Error('Parallel Research cancelled.');
|
|
273
|
+
if (deadline.signal.aborted)
|
|
274
|
+
throw new Error('Parallel Research timed out after 120 seconds.');
|
|
275
|
+
// JSON parser errors can include body excerpts, even partial credentials.
|
|
276
|
+
if (error instanceof SyntaxError)
|
|
277
|
+
throw new Error('Parallel returned malformed research JSON.');
|
|
278
|
+
throw safeError(error, apiKey);
|
|
279
|
+
} finally {
|
|
280
|
+
clearTimeout(timeout);
|
|
281
|
+
}
|
|
282
|
+
}
|