@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,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
|
+
}
|