@normful/parallel-web-pi-extension 0.0.0-stage → 1.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.
@@ -0,0 +1,116 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionContext,
4
+ } from '@earendil-works/pi-coding-agent';
5
+ import type {
6
+ ApiKeyCredential,
7
+ AuthContext,
8
+ AuthResult,
9
+ Provider,
10
+ ProviderAuthInteraction,
11
+ } from '@earendil-works/pi-ai';
12
+ import { loginWithParallel as runParallelOAuth } from './oauth/index.js';
13
+
14
+ /** Provider id under which Pi stores the Parallel credential in its auth store. */
15
+ export const PARALLEL_PROVIDER = 'parallel';
16
+
17
+ const API_KEY_ENV_VAR = 'PARALLEL_API_KEY';
18
+
19
+ async function loginToParallel(
20
+ interaction: ProviderAuthInteraction
21
+ ): Promise<ApiKeyCredential> {
22
+ interaction.signal.throwIfAborted();
23
+
24
+ const { apiKey } = await runParallelOAuth({
25
+ openBrowser: false,
26
+ onAuthUrl: (url) => {
27
+ interaction.notify({
28
+ type: 'auth_url',
29
+ url,
30
+ instructions: 'Opening Parallel login in your browser.',
31
+ });
32
+ },
33
+ promptForCallback: async (authUrl) => {
34
+ try {
35
+ return await interaction.prompt({
36
+ type: 'manual_code',
37
+ message: 'Paste the Parallel callback URL from your browser',
38
+ placeholder: authUrl,
39
+ signal: interaction.signal,
40
+ });
41
+ } catch {
42
+ // Cancelled or aborted: abort the login rather than keep waiting.
43
+ return undefined;
44
+ }
45
+ },
46
+ });
47
+
48
+ interaction.signal.throwIfAborted();
49
+ return { type: 'api_key', key: apiKey };
50
+ }
51
+
52
+ async function resolveParallelAuth(input: {
53
+ ctx: AuthContext;
54
+ credential?: ApiKeyCredential;
55
+ signal: AbortSignal;
56
+ }): Promise<AuthResult | undefined> {
57
+ input.signal.throwIfAborted();
58
+
59
+ if (input.credential?.key) {
60
+ return {
61
+ auth: { apiKey: input.credential.key },
62
+ source: 'stored credential',
63
+ };
64
+ }
65
+
66
+ const envApiKey = await input.ctx.env(API_KEY_ENV_VAR);
67
+ return envApiKey
68
+ ? { auth: { apiKey: envApiKey }, source: API_KEY_ENV_VAR }
69
+ : undefined;
70
+ }
71
+
72
+ /**
73
+ * This provider only carries credentials. Pi owns auth.json, the login/logout
74
+ * flows and environment fallback; every web tool reuses the resolved key.
75
+ */
76
+ function createParallelProvider(): Provider {
77
+ return {
78
+ id: PARALLEL_PROVIDER,
79
+ name: 'Parallel',
80
+ auth: {
81
+ apiKey: {
82
+ name: 'Parallel',
83
+ login: loginToParallel,
84
+ resolve: resolveParallelAuth,
85
+ },
86
+ },
87
+ getModels: () => [],
88
+ stream() {
89
+ throw new Error('The Parallel provider does not serve models.');
90
+ },
91
+ streamSimple() {
92
+ throw new Error('The Parallel provider does not serve models.');
93
+ },
94
+ };
95
+ }
96
+
97
+ export function registerParallelAuthProvider(pi: ExtensionAPI) {
98
+ pi.registerProvider(createParallelProvider());
99
+ }
100
+
101
+ export async function getParallelApiKey(ctx: ExtensionContext) {
102
+ return await ctx.modelRegistry.getApiKeyForProvider(PARALLEL_PROVIDER);
103
+ }
104
+
105
+ /** Structural mirror of Pi's AuthStatus, which it does not export from its root. */
106
+ export type ParallelAuthStatus = {
107
+ configured: boolean;
108
+ source?: string;
109
+ label?: string;
110
+ };
111
+
112
+ export function getParallelAuthStatus(
113
+ ctx: ExtensionContext
114
+ ): ParallelAuthStatus {
115
+ return ctx.modelRegistry.getProviderAuthStatus(PARALLEL_PROVIDER);
116
+ }
@@ -0,0 +1,84 @@
1
+ import Parallel from 'parallel-web';
2
+ import pkg from '../package.json' with { type: 'json' };
3
+
4
+ export interface ParallelSearchInput {
5
+ objective: string;
6
+ search_queries: string[];
7
+ client_model?: string;
8
+ session_id?: string;
9
+ }
10
+
11
+ export interface ParallelExtractInput {
12
+ urls: string[];
13
+ objective?: string;
14
+ search_queries?: string[];
15
+ client_model?: string;
16
+ session_id?: string;
17
+ }
18
+
19
+ function createParallelClient(apiKey: string) {
20
+ return new Parallel({
21
+ apiKey,
22
+ defaultHeaders: {
23
+ 'X-Tool-Calling-Package': `npm:@normful/parallel-web-pi-extension/v${pkg.version}`,
24
+ },
25
+ });
26
+ }
27
+
28
+ export async function runParallelSearch(
29
+ apiKey: string,
30
+ input: ParallelSearchInput,
31
+ signal?: AbortSignal
32
+ ) {
33
+ const client = createParallelClient(apiKey);
34
+ return await client.search(
35
+ {
36
+ objective: input.objective,
37
+ search_queries: input.search_queries,
38
+ mode: 'fast',
39
+ client_model: input.client_model,
40
+ session_id: input.session_id,
41
+ },
42
+ { signal }
43
+ );
44
+ }
45
+
46
+ export async function runParallelExtract(
47
+ apiKey: string,
48
+ input: ParallelExtractInput,
49
+ signal?: AbortSignal
50
+ ) {
51
+ const client = createParallelClient(apiKey);
52
+ // Rely on the default excerpt behavior: with an objective/search_queries the
53
+ // excerpts are focused on the relevant content; without them Extract returns
54
+ // whole-page markdown. We intentionally do NOT force `full_content` — enabling
55
+ // it without an objective/search_queries makes excerpts redundant (and the API
56
+ // warns about it). See https://docs.parallel.ai/extract/best-practices.
57
+ return await client.extract(
58
+ {
59
+ urls: input.urls,
60
+ objective: input.objective,
61
+ search_queries: input.search_queries,
62
+ client_model: input.client_model,
63
+ session_id: input.session_id,
64
+ },
65
+ { signal }
66
+ );
67
+ }
68
+
69
+ export function isParallelAuthenticationError(error: unknown) {
70
+ if (!(error instanceof Error)) {
71
+ return false;
72
+ }
73
+
74
+ const status =
75
+ typeof error === 'object' && error !== null && 'status' in error
76
+ ? (error as { status?: number }).status
77
+ : undefined;
78
+
79
+ return (
80
+ status === 401 ||
81
+ error.name === 'AuthenticationError' ||
82
+ /unauthorized|authentication|api key/i.test(error.message)
83
+ );
84
+ }
@@ -0,0 +1,304 @@
1
+ import pkg from '../package.json' with { type: 'json' };
2
+
3
+ /**
4
+ * Default API origin used when PARALLEL_BASE_URL is unset. Matches the value
5
+ * hardcoded into the parallel-web SDK, so a single variable can point every
6
+ * web tool at the same host.
7
+ */
8
+ export const PARALLEL_API_ORIGIN = 'https://api.parallel.ai';
9
+ export const PARALLEL_RESPONSES_PATH = '/v1/responses';
10
+ /** Responses endpoint on the default origin. */
11
+ export const PARALLEL_RESPONSES_URL = `${PARALLEL_API_ORIGIN}${PARALLEL_RESPONSES_PATH}`;
12
+
13
+ /**
14
+ * Resolve the Responses endpoint, honoring PARALLEL_BASE_URL exactly as the
15
+ * parallel-web SDK does for web_search and web_fetch. The value is an origin:
16
+ * the `/v1/...` path is always appended, and a trailing slash is tolerated.
17
+ */
18
+ export function resolveParallelResponsesUrl(
19
+ baseUrl: string | undefined = process.env.PARALLEL_BASE_URL
20
+ ) {
21
+ const origin = baseUrl?.trim().replace(/\/+$/, '');
22
+ return origin
23
+ ? `${origin}${PARALLEL_RESPONSES_PATH}`
24
+ : PARALLEL_RESPONSES_URL;
25
+ }
26
+ export const PARALLEL_RESPONSES_MAX_INPUT_CHARS = 20_000;
27
+ export const PARALLEL_RESPONSES_TIMEOUT_MS = 120_000;
28
+ export const DEFAULT_RESEARCH_EFFORT = 'medium';
29
+ export const MAX_RESPONSE_ID_LENGTH = 512;
30
+
31
+ export type ResearchEffort = 'low' | 'medium' | 'high';
32
+
33
+ export interface ResearchInput {
34
+ query: string;
35
+ effort?: ResearchEffort;
36
+ previous_response_id?: string;
37
+ }
38
+
39
+ // Only these instructions, the explicit question and optional continuation ID
40
+ // cross the research boundary.
41
+ // Do not assemble this prompt from Pi's session, system prompt, or local files.
42
+ const RESEARCH_INSTRUCTIONS =
43
+ "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.";
44
+
45
+ function isRecord(value: unknown): value is Record<string, unknown> {
46
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
47
+ }
48
+
49
+ // IDs are opaque. Bound their size and keep the displayed continuation header
50
+ // on one line without coupling the tool to the server's current ID format.
51
+ function isResponseId(value: unknown): value is string {
52
+ return (
53
+ typeof value === 'string' &&
54
+ value.length > 0 &&
55
+ value.length <= MAX_RESPONSE_ID_LENGTH &&
56
+ !/[\s\p{Cc}]/u.test(value)
57
+ );
58
+ }
59
+
60
+ function renderResearch(payload: unknown): string {
61
+ if (!isRecord(payload) || payload.status !== 'completed') {
62
+ throw new Error('Parallel returned a response that was not completed.');
63
+ }
64
+ if (!Array.isArray(payload.output)) {
65
+ throw new Error('Parallel returned a response without output messages.');
66
+ }
67
+
68
+ const texts: string[] = [];
69
+ const sources = new Map<string, { title: string; passages: Set<string> }>();
70
+ for (const item of payload.output) {
71
+ if (!isRecord(item) || typeof item.type !== 'string') {
72
+ throw new Error('Parallel returned malformed research output.');
73
+ }
74
+ if (item.type !== 'message') continue;
75
+ if (item.status !== undefined && item.status !== 'completed') {
76
+ throw new Error('Parallel returned an answer that was not completed.');
77
+ }
78
+ if (!Array.isArray(item.content)) {
79
+ throw new Error('Parallel returned malformed research output.');
80
+ }
81
+ for (const content of item.content) {
82
+ if (!isRecord(content) || typeof content.type !== 'string') {
83
+ throw new Error('Parallel returned malformed research output.');
84
+ }
85
+ if (content.type !== 'output_text') continue;
86
+ if (typeof content.text !== 'string') {
87
+ throw new Error('Parallel returned malformed research output.');
88
+ }
89
+ texts.push(content.text);
90
+ if (!Array.isArray(content.annotations)) continue;
91
+ // API citation offsets count Unicode code points, not UTF-16 units.
92
+ const characters = [...content.text];
93
+ for (const citation of content.annotations) {
94
+ if (
95
+ !isRecord(citation) ||
96
+ citation.type !== 'url_citation' ||
97
+ typeof citation.url !== 'string'
98
+ ) {
99
+ continue;
100
+ }
101
+ let url: URL;
102
+ try {
103
+ url = new URL(citation.url);
104
+ } catch {
105
+ continue;
106
+ }
107
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') continue;
108
+ const href = url.toString();
109
+ const title =
110
+ typeof citation.title === 'string' ? citation.title.trim() : '';
111
+ let source = sources.get(href);
112
+ if (!source) {
113
+ source = { title: title || href, passages: new Set() };
114
+ sources.set(href, source);
115
+ }
116
+ const start = citation.start_index;
117
+ const end = citation.end_index;
118
+ if (
119
+ typeof start === 'number' &&
120
+ typeof end === 'number' &&
121
+ Number.isSafeInteger(start) &&
122
+ Number.isSafeInteger(end) &&
123
+ start >= 0 &&
124
+ end > start &&
125
+ end <= characters.length
126
+ ) {
127
+ const passage = characters.slice(start, end).join('');
128
+ source.passages.add(
129
+ `Cited answer passage (part ${texts.length}, characters ${start}:${end}): ${JSON.stringify(passage)}`
130
+ );
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ const text = texts.join('\n\n');
137
+ if (!text.trim())
138
+ throw new Error('Parallel returned an empty research response.');
139
+ if (sources.size === 0) return text;
140
+
141
+ const list = [...sources].map(([url, { title, passages }], index) => {
142
+ const label = title
143
+ .replaceAll('\\', '\\\\')
144
+ .replaceAll('[', '\\[')
145
+ .replaceAll(']', '\\]')
146
+ .replace(/[\r\n]+/g, ' ');
147
+ const href = url
148
+ .replaceAll('\\', '%5C')
149
+ .replaceAll('<', '%3C')
150
+ .replaceAll('>', '%3E');
151
+ const link = `${index + 1}. [${label}](<${href}>)`;
152
+ return [link, ...[...passages].map((passage) => ` ${passage}`)].join(
153
+ '\n'
154
+ );
155
+ });
156
+ const rangeNote = [...sources.values()].some((source) => source.passages.size)
157
+ ? 'Passage locations use zero-based Unicode character offsets within each original text part; the end is exclusive.\n'
158
+ : '';
159
+ return `${text}\n\nSources:\n${rangeNote}${list.join('\n')}`;
160
+ }
161
+
162
+ function safeError(error: unknown, apiKey: string): Error {
163
+ let message: string;
164
+ try {
165
+ message = error instanceof Error ? error.message : String(error);
166
+ } catch {
167
+ message = 'Unknown research failure';
168
+ }
169
+ if (apiKey) message = message.replaceAll(apiKey, '[REDACTED]');
170
+ const result = new Error(
171
+ (message.trim() || 'Unknown research failure').slice(0, 1_000)
172
+ );
173
+ // Preserve HTTP status for the extension's existing authentication guidance.
174
+ if (isRecord(error) && typeof error.status === 'number') {
175
+ Object.assign(result, { status: error.status });
176
+ }
177
+ return result;
178
+ }
179
+
180
+ export function parseResearchInput(
181
+ input: unknown
182
+ ): ResearchInput & { effort: ResearchEffort } {
183
+ if (
184
+ !isRecord(input) ||
185
+ typeof input.query !== 'string' ||
186
+ !input.query.trim()
187
+ ) {
188
+ throw new Error('Parallel Research requires a non-empty question.');
189
+ }
190
+ const effort =
191
+ input.effort === undefined ? DEFAULT_RESEARCH_EFFORT : input.effort;
192
+ if (effort !== 'low' && effort !== 'medium' && effort !== 'high') {
193
+ throw new Error('Parallel Research effort must be low, medium, or high.');
194
+ }
195
+ if (
196
+ [...`${RESEARCH_INSTRUCTIONS}\n${input.query}`].length >
197
+ PARALLEL_RESPONSES_MAX_INPUT_CHARS
198
+ ) {
199
+ throw new Error(
200
+ 'Parallel Research exceeds the 20,000-character input limit, including research instructions.'
201
+ );
202
+ }
203
+ const previousResponseId = input.previous_response_id;
204
+ if (previousResponseId !== undefined && !isResponseId(previousResponseId)) {
205
+ throw new Error(
206
+ `Parallel Research previous_response_id must be a non-empty string of at most ${MAX_RESPONSE_ID_LENGTH} characters without whitespace or control characters.`
207
+ );
208
+ }
209
+ return {
210
+ query: input.query,
211
+ effort,
212
+ ...(previousResponseId !== undefined
213
+ ? { previous_response_id: previousResponseId }
214
+ : {}),
215
+ };
216
+ }
217
+
218
+ export async function runParallelResearch(
219
+ apiKey: string,
220
+ input: ResearchInput,
221
+ signal?: AbortSignal
222
+ ): Promise<{ text: string; effort: ResearchEffort; responseId?: string }> {
223
+ const { query, effort, previous_response_id } = parseResearchInput(input);
224
+ if (!apiKey) {
225
+ throw new Error(
226
+ 'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.'
227
+ );
228
+ }
229
+
230
+ const deadline = new AbortController();
231
+ const requestSignal = signal
232
+ ? AbortSignal.any([signal, deadline.signal])
233
+ : deadline.signal;
234
+ // Keep the deadline active through body consumption, not just response headers.
235
+ const timeout = setTimeout(
236
+ () => deadline.abort(),
237
+ PARALLEL_RESPONSES_TIMEOUT_MS
238
+ );
239
+ timeout.unref?.();
240
+ try {
241
+ requestSignal.throwIfAborted();
242
+ const response = await fetch(resolveParallelResponsesUrl(), {
243
+ method: 'POST',
244
+ headers: {
245
+ Authorization: `Bearer ${apiKey}`,
246
+ 'Content-Type': 'application/json',
247
+ 'X-Tool-Calling-Package': `npm:@normful/parallel-web-pi-extension/v${pkg.version}`,
248
+ },
249
+ body: JSON.stringify({
250
+ model: 'parallel',
251
+ input: query,
252
+ instructions: RESEARCH_INSTRUCTIONS,
253
+ reasoning: { effort },
254
+ stream: false,
255
+ previous_response_id,
256
+ }),
257
+ signal: requestSignal,
258
+ redirect: 'error',
259
+ });
260
+
261
+ if (!response.ok) {
262
+ let message = response.statusText || 'request failed';
263
+ try {
264
+ const payload: unknown = await response.json();
265
+ if (
266
+ isRecord(payload) &&
267
+ isRecord(payload.error) &&
268
+ typeof payload.error.message === 'string'
269
+ ) {
270
+ message = payload.error.message;
271
+ }
272
+ } catch {
273
+ // A non-JSON error still has a useful HTTP status.
274
+ }
275
+ requestSignal.throwIfAborted();
276
+ throw Object.assign(
277
+ new Error(
278
+ `Parallel Responses request failed (${response.status}): ${message}`
279
+ ),
280
+ { status: response.status }
281
+ );
282
+ }
283
+
284
+ const payload: unknown = await response.json();
285
+ requestSignal.throwIfAborted();
286
+ return {
287
+ text: renderResearch(payload),
288
+ effort,
289
+ ...(isRecord(payload) && isResponseId(payload.id)
290
+ ? { responseId: payload.id }
291
+ : {}),
292
+ };
293
+ } catch (error) {
294
+ if (signal?.aborted) throw new Error('Parallel Research cancelled.');
295
+ if (deadline.signal.aborted)
296
+ throw new Error('Parallel Research timed out after 120 seconds.');
297
+ // JSON parser errors can include body excerpts, even partial credentials.
298
+ if (error instanceof SyntaxError)
299
+ throw new Error('Parallel returned malformed research JSON.');
300
+ throw safeError(error, apiKey);
301
+ } finally {
302
+ clearTimeout(timeout);
303
+ }
304
+ }