@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.
@@ -0,0 +1,256 @@
1
+ import { createHash, randomBytes, randomUUID } from 'node:crypto';
2
+ import { spawn } from 'node:child_process';
3
+ import {
4
+ createServer,
5
+ type IncomingMessage,
6
+ type ServerResponse,
7
+ } from 'node:http';
8
+ import type { AddressInfo } from 'node:net';
9
+
10
+ const DEFAULT_PLATFORM_ORIGIN = 'https://platform.parallel.ai';
11
+ const DEFAULT_CALLBACK_TIMEOUT_MS = 120_000;
12
+ /** Loopback host the local OAuth callback server binds to. */
13
+ const LOOPBACK_HOST = '127.0.0.1';
14
+
15
+ export interface LoginWithParallelOptions {
16
+ /** Override the platform origin. Defaults to PARALLEL_PLATFORM_URL or https://platform.parallel.ai. */
17
+ platformOrigin?: string;
18
+ /** Milliseconds to wait for the browser callback before giving up. */
19
+ callbackTimeoutMs?: number;
20
+ /** Whether to attempt to open the auth URL in the user's default browser. Default true. */
21
+ openBrowser?: boolean;
22
+ /** Invoked once the auth URL is known, before waiting on the callback. */
23
+ onAuthUrl?: (authUrl: string, browserOpened: boolean) => void;
24
+ /**
25
+ * Called if the loopback callback times out, to ask the user to paste the
26
+ * callback URL manually. Should return undefined to abort.
27
+ */
28
+ promptForCallback?: (authUrl: string) => Promise<string | undefined>;
29
+ }
30
+
31
+ export interface LoginWithParallelResult {
32
+ apiKey: string;
33
+ }
34
+
35
+ function resolvePlatformOrigin(override?: string) {
36
+ const raw =
37
+ override ?? process.env.PARALLEL_PLATFORM_URL ?? DEFAULT_PLATFORM_ORIGIN;
38
+ return raw.replace(/\/$/, '');
39
+ }
40
+
41
+ function toBase64Url(value: Buffer) {
42
+ return value
43
+ .toString('base64')
44
+ .replace(/\+/g, '-')
45
+ .replace(/\//g, '_')
46
+ .replace(/=+$/g, '');
47
+ }
48
+
49
+ function generatePkce() {
50
+ const verifier = toBase64Url(randomBytes(32));
51
+ const challenge = toBase64Url(createHash('sha256').update(verifier).digest());
52
+ return { verifier, challenge };
53
+ }
54
+
55
+ function openExternalUrl(url: string) {
56
+ try {
57
+ if (process.platform === 'darwin') {
58
+ const child = spawn('open', [url], { detached: true, stdio: 'ignore' });
59
+ child.unref();
60
+ return true;
61
+ }
62
+ if (process.platform === 'win32') {
63
+ const child = spawn('cmd', ['/c', 'start', '', url], {
64
+ detached: true,
65
+ stdio: 'ignore',
66
+ });
67
+ child.unref();
68
+ return true;
69
+ }
70
+ const child = spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
71
+ child.unref();
72
+ return true;
73
+ } catch {
74
+ return false;
75
+ }
76
+ }
77
+
78
+ function writeCallbackResponse(
79
+ res: ServerResponse,
80
+ statusCode: number,
81
+ body: string
82
+ ) {
83
+ res.writeHead(statusCode, { 'Content-Type': 'text/html; charset=utf-8' });
84
+ res.end(`<!doctype html><html><body><p>${body}</p></body></html>`);
85
+ }
86
+
87
+ async function startCallbackListener() {
88
+ let resolveCallback: ((value: string) => void) | undefined;
89
+ let rejectCallback: ((reason?: unknown) => void) | undefined;
90
+
91
+ const callbackUrlPromise = new Promise<string>((resolve, reject) => {
92
+ resolveCallback = resolve;
93
+ rejectCallback = reject;
94
+ });
95
+
96
+ const server = createServer((req: IncomingMessage, res: ServerResponse) => {
97
+ const requestUrl = req.url ?? '/';
98
+ const callbackUrl = `${callbackOrigin}${requestUrl}`;
99
+ const url = new URL(callbackUrl);
100
+
101
+ if (url.pathname !== '/callback') {
102
+ writeCallbackResponse(res, 404, 'Not found.');
103
+ return;
104
+ }
105
+
106
+ if (url.searchParams.get('error')) {
107
+ writeCallbackResponse(
108
+ res,
109
+ 400,
110
+ 'Parallel login was denied. You can close this tab.'
111
+ );
112
+ } else {
113
+ writeCallbackResponse(
114
+ res,
115
+ 200,
116
+ 'Parallel login completed. You can close this tab.'
117
+ );
118
+ }
119
+
120
+ resolveCallback?.(callbackUrl);
121
+ });
122
+
123
+ await new Promise<void>((resolve, reject) => {
124
+ server.once('error', reject);
125
+ server.listen(0, LOOPBACK_HOST, () => {
126
+ server.off('error', reject);
127
+ resolve();
128
+ });
129
+ });
130
+
131
+ const address = server.address() as AddressInfo;
132
+ const callbackOrigin = `http://${LOOPBACK_HOST}:${address.port}`;
133
+ const redirectUri = `${callbackOrigin}/callback`;
134
+
135
+ return {
136
+ redirectUri,
137
+ async waitForCallbackUrl(timeoutMs = DEFAULT_CALLBACK_TIMEOUT_MS) {
138
+ const timer = setTimeout(() => {
139
+ rejectCallback?.(new Error('Parallel login timed out.'));
140
+ }, timeoutMs);
141
+ try {
142
+ return await callbackUrlPromise;
143
+ } finally {
144
+ clearTimeout(timer);
145
+ }
146
+ },
147
+ async close() {
148
+ await new Promise<void>((resolve) => {
149
+ server.close(() => resolve());
150
+ server.closeAllConnections();
151
+ });
152
+ },
153
+ };
154
+ }
155
+
156
+ async function exchangeCodeForApiKey(
157
+ platformOrigin: string,
158
+ code: string,
159
+ verifier: string,
160
+ redirectUri: string
161
+ ) {
162
+ const response = await fetch(`${platformOrigin}/getKeys/token`, {
163
+ method: 'POST',
164
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
165
+ body: new URLSearchParams({
166
+ grant_type: 'authorization_code',
167
+ code,
168
+ client_id: new URL(redirectUri).hostname,
169
+ redirect_uri: redirectUri,
170
+ code_verifier: verifier,
171
+ }),
172
+ });
173
+
174
+ if (!response.ok) {
175
+ throw new Error(`Parallel token exchange failed: ${await response.text()}`);
176
+ }
177
+
178
+ const payload = (await response.json()) as { access_token?: string };
179
+ if (!payload.access_token) {
180
+ throw new Error('Parallel token exchange did not return an API key.');
181
+ }
182
+ return payload.access_token;
183
+ }
184
+
185
+ /**
186
+ * Runs the PKCE OAuth flow against platform.parallel.ai and returns a
187
+ * long-lived API key. Storage of that key is the caller's responsibility.
188
+ */
189
+ export async function loginWithParallel(
190
+ opts: LoginWithParallelOptions = {}
191
+ ): Promise<LoginWithParallelResult> {
192
+ const platformOrigin = resolvePlatformOrigin(opts.platformOrigin);
193
+ const callbackListener = await startCallbackListener();
194
+ const { verifier, challenge } = generatePkce();
195
+ const state = randomUUID();
196
+
197
+ try {
198
+ const authUrl = new URL(`${platformOrigin}/getKeys/authorize`);
199
+ authUrl.searchParams.set(
200
+ 'client_id',
201
+ new URL(callbackListener.redirectUri).hostname
202
+ );
203
+ authUrl.searchParams.set('redirect_uri', callbackListener.redirectUri);
204
+ authUrl.searchParams.set('response_type', 'code');
205
+ authUrl.searchParams.set('scope', 'key:read');
206
+ authUrl.searchParams.set('code_challenge', challenge);
207
+ authUrl.searchParams.set('code_challenge_method', 'S256');
208
+ authUrl.searchParams.set('state', state);
209
+
210
+ const browserOpened =
211
+ opts.openBrowser === false ? false : openExternalUrl(authUrl.toString());
212
+
213
+ opts.onAuthUrl?.(authUrl.toString(), browserOpened);
214
+
215
+ let callbackUrl: string | undefined;
216
+ try {
217
+ callbackUrl = await callbackListener.waitForCallbackUrl(
218
+ opts.callbackTimeoutMs
219
+ );
220
+ } catch {
221
+ callbackUrl = await opts.promptForCallback?.(authUrl.toString());
222
+ }
223
+
224
+ if (!callbackUrl) {
225
+ throw new Error('Parallel login was not completed.');
226
+ }
227
+
228
+ const url = new URL(callbackUrl);
229
+ if (url.searchParams.get('state') !== state) {
230
+ throw new Error('Parallel login state check failed.');
231
+ }
232
+ if (url.searchParams.get('error')) {
233
+ throw new Error(
234
+ `Parallel login failed: ${url.searchParams.get('error_description') ?? url.searchParams.get('error')}`
235
+ );
236
+ }
237
+
238
+ const code = url.searchParams.get('code');
239
+ if (!code) {
240
+ throw new Error(
241
+ 'Parallel login callback did not include an authorization code.'
242
+ );
243
+ }
244
+
245
+ const apiKey = await exchangeCodeForApiKey(
246
+ platformOrigin,
247
+ code,
248
+ verifier,
249
+ callbackListener.redirectUri
250
+ );
251
+
252
+ return { apiKey };
253
+ } finally {
254
+ await callbackListener.close();
255
+ }
256
+ }
@@ -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
+ }