@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.
- package/LICENSE +23 -0
- package/README.md +249 -2
- package/package.json +64 -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 +304 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdtemp, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { tmpdir } from 'node:os';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import type {
|
|
6
|
+
ExtensionAPI,
|
|
7
|
+
ExtensionContext,
|
|
8
|
+
} from '@earendil-works/pi-coding-agent';
|
|
9
|
+
import {
|
|
10
|
+
DEFAULT_MAX_BYTES,
|
|
11
|
+
DEFAULT_MAX_LINES,
|
|
12
|
+
formatSize,
|
|
13
|
+
truncateHead,
|
|
14
|
+
} from '@earendil-works/pi-coding-agent';
|
|
15
|
+
import { Type } from 'typebox';
|
|
16
|
+
import {
|
|
17
|
+
MAX_RESPONSE_ID_LENGTH,
|
|
18
|
+
parseResearchInput,
|
|
19
|
+
runParallelResearch,
|
|
20
|
+
} from './parallel-responses';
|
|
21
|
+
import {
|
|
22
|
+
getParallelApiKey,
|
|
23
|
+
getParallelAuthStatus,
|
|
24
|
+
registerParallelAuthProvider,
|
|
25
|
+
} from './parallel-auth';
|
|
26
|
+
import {
|
|
27
|
+
isParallelAuthenticationError,
|
|
28
|
+
runParallelExtract,
|
|
29
|
+
runParallelSearch,
|
|
30
|
+
} from './parallel-client';
|
|
31
|
+
|
|
32
|
+
function truncateJson(value: unknown) {
|
|
33
|
+
const pretty = JSON.stringify(value, null, 2);
|
|
34
|
+
const truncation = truncateHead(pretty, {
|
|
35
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
36
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
let text = truncation.content;
|
|
40
|
+
if (truncation.truncated) {
|
|
41
|
+
text += `\n\n[Output truncated: showing ${truncation.outputLines} of ${truncation.totalLines} lines (${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)})]`;
|
|
42
|
+
}
|
|
43
|
+
return text;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const SUPPRESSED_SKILL_NAMES = new Set([
|
|
47
|
+
'parallel-cli-setup',
|
|
48
|
+
'parallel-web-extract',
|
|
49
|
+
'parallel-web-search',
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function escapeRegex(value: string) {
|
|
53
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function suppressSkillsFromPrompt(systemPrompt: string) {
|
|
57
|
+
let nextPrompt = systemPrompt;
|
|
58
|
+
|
|
59
|
+
for (const skillName of SUPPRESSED_SKILL_NAMES) {
|
|
60
|
+
const pattern = new RegExp(
|
|
61
|
+
String.raw`\n?\s*<skill>\s*<name>${escapeRegex(skillName)}</name>[\s\S]*?<\/skill>`,
|
|
62
|
+
'g'
|
|
63
|
+
);
|
|
64
|
+
nextPrompt = nextPrompt.replace(pattern, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return nextPrompt.replace(
|
|
68
|
+
/\n?<available_skills>\s*<\/available_skills>/g,
|
|
69
|
+
''
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const WEB_TOOL_GUIDANCE = {
|
|
74
|
+
web_research:
|
|
75
|
+
'Use web_research for a complete answer that requires current web research and synthesis. Start with the full self-contained question, including constraints. For a focused follow-up, pass the latest returned Response ID as previous_response_id; omit it for unrelated research.',
|
|
76
|
+
web_search:
|
|
77
|
+
'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.',
|
|
78
|
+
web_fetch:
|
|
79
|
+
'Use web_fetch to read a known URL or inspect the original source behind a claim.',
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export default function (pi: ExtensionAPI) {
|
|
83
|
+
const parallelSessionId = randomUUID();
|
|
84
|
+
|
|
85
|
+
registerParallelAuthProvider(pi);
|
|
86
|
+
|
|
87
|
+
async function resolveApiKey(ctx: ExtensionContext) {
|
|
88
|
+
const apiKey = await getParallelApiKey(ctx);
|
|
89
|
+
if (apiKey) {
|
|
90
|
+
return apiKey;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
throw new Error(
|
|
94
|
+
'Parallel authentication required. Run `/login parallel` in Pi, or set PARALLEL_API_KEY.'
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function runWithAuth<T>(
|
|
99
|
+
ctx: ExtensionContext,
|
|
100
|
+
request: (apiKey: string) => Promise<T>,
|
|
101
|
+
isAuthenticationError = isParallelAuthenticationError
|
|
102
|
+
) {
|
|
103
|
+
const apiKey = await resolveApiKey(ctx);
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
return await request(apiKey);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (!isAuthenticationError(error)) {
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (process.env.PARALLEL_API_KEY) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
'Parallel rejected PARALLEL_API_KEY. Update or unset it, then try again.'
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
throw new Error(
|
|
119
|
+
'Parallel rejected the stored credential. Run `/login parallel` in Pi to sign in again.'
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
pi.registerCommand('parallel-login', {
|
|
125
|
+
description: 'Show Parallel authentication status and how to sign in',
|
|
126
|
+
handler: async (_args, ctx) => {
|
|
127
|
+
// getProviderAuthStatus is a synchronous snapshot that only knows about
|
|
128
|
+
// stored credentials, so ask for the resolved key to catch the env var too.
|
|
129
|
+
const apiKey = await getParallelApiKey(ctx);
|
|
130
|
+
if (!apiKey) {
|
|
131
|
+
ctx.ui.notify(
|
|
132
|
+
'Parallel is not authenticated. Run `/login parallel` to sign in, or set PARALLEL_API_KEY.',
|
|
133
|
+
'info'
|
|
134
|
+
);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const status = getParallelAuthStatus(ctx);
|
|
139
|
+
const source = status.label ?? status.source ?? 'PARALLEL_API_KEY';
|
|
140
|
+
const guidance = status.configured
|
|
141
|
+
? 'Run `/login parallel` to replace the stored credential, or `/logout` and select Parallel to remove it.'
|
|
142
|
+
: 'Run `/login parallel` to store a credential, or unset PARALLEL_API_KEY to remove the current one.';
|
|
143
|
+
ctx.ui.notify(
|
|
144
|
+
`Parallel is authenticated (${source}). ${guidance}`,
|
|
145
|
+
'info'
|
|
146
|
+
);
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
pi.on('before_agent_start', async (event) => {
|
|
151
|
+
const filteredPrompt = suppressSkillsFromPrompt(event.systemPrompt);
|
|
152
|
+
const selectedTools = event.systemPromptOptions.selectedTools ?? [];
|
|
153
|
+
const guidance = Object.entries(WEB_TOOL_GUIDANCE)
|
|
154
|
+
.filter(([tool]) => selectedTools.includes(tool))
|
|
155
|
+
.map(([, text]) => `- ${text}`);
|
|
156
|
+
|
|
157
|
+
if (guidance.length === 0) {
|
|
158
|
+
return filteredPrompt === event.systemPrompt
|
|
159
|
+
? undefined
|
|
160
|
+
: { systemPrompt: filteredPrompt };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
systemPrompt: `${filteredPrompt}\n\n## Grounding and web usage\n\nUse available web tools when current information or source evidence would improve the answer.\n\n${guidance.join('\n')}\n\nWhen citing web evidence, include the returned source URLs as clickable Markdown links. Do not replace source links with source names alone.`,
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
pi.registerTool({
|
|
169
|
+
name: 'web_research',
|
|
170
|
+
label: 'Web Research',
|
|
171
|
+
description:
|
|
172
|
+
"Answer a complete question using Parallel's Responses API. Delegates multi-step web research and returns a synthesized answer with sources, not raw search results. One call can handle the full research question.",
|
|
173
|
+
promptSnippet:
|
|
174
|
+
'Get a cited answer to a complete web research question using Parallel',
|
|
175
|
+
promptGuidelines: [
|
|
176
|
+
'Use web_research for questions that need web research and synthesis; pass the complete question in one call before making focused follow-ups.',
|
|
177
|
+
'Include dates, constraints, and relevant context in query. Research cannot see this Pi conversation or local files; include only context that is safe to send.',
|
|
178
|
+
'For a follow-up on the same investigation, pass its latest returned Response ID as previous_response_id to reuse prior research context. Omit it for unrelated questions. If no ID was returned, continuation is unavailable.',
|
|
179
|
+
'Use low effort for focused lookups, medium for general research, and high only for extensive research. The default is medium.',
|
|
180
|
+
],
|
|
181
|
+
parameters: Type.Object({
|
|
182
|
+
query: Type.String({
|
|
183
|
+
minLength: 1,
|
|
184
|
+
description:
|
|
185
|
+
'The research question and constraints that are safe to send. Make a new question self-contained; a follow-up with previous_response_id may refer to prior research. Research cannot see the Pi conversation or local files.',
|
|
186
|
+
}),
|
|
187
|
+
effort: Type.Optional(
|
|
188
|
+
Type.Union(
|
|
189
|
+
[Type.Literal('low'), Type.Literal('medium'), Type.Literal('high')],
|
|
190
|
+
{
|
|
191
|
+
description:
|
|
192
|
+
'Research depth: low for focused lookups, medium for general research (default), high for extensive research.',
|
|
193
|
+
}
|
|
194
|
+
)
|
|
195
|
+
),
|
|
196
|
+
previous_response_id: Type.Optional(
|
|
197
|
+
Type.String({
|
|
198
|
+
minLength: 1,
|
|
199
|
+
maxLength: MAX_RESPONSE_ID_LENGTH,
|
|
200
|
+
description:
|
|
201
|
+
'The latest Response ID returned by web_research for this investigation. Reuses prior research context for a follow-up. Omit for a new or unrelated question; never invent an ID.',
|
|
202
|
+
})
|
|
203
|
+
),
|
|
204
|
+
}),
|
|
205
|
+
// Reject invalid raw values before Pi coerces them to schema types.
|
|
206
|
+
prepareArguments: parseResearchInput,
|
|
207
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
208
|
+
onUpdate?.({
|
|
209
|
+
content: [{ type: 'text', text: 'Researching the web...' }],
|
|
210
|
+
details: { provider: 'parallel', product: 'responses' },
|
|
211
|
+
});
|
|
212
|
+
const result = await runWithAuth(
|
|
213
|
+
ctx,
|
|
214
|
+
(apiKey) => runParallelResearch(apiKey, params, signal),
|
|
215
|
+
// Responses errors carry HTTP status. Their text can echo opaque IDs,
|
|
216
|
+
// so words such as "unauthorized" do not identify rejected credentials.
|
|
217
|
+
(error) =>
|
|
218
|
+
error instanceof Error && 'status' in error && error.status === 401
|
|
219
|
+
);
|
|
220
|
+
// Pi sends content to the parent, not details. Keep the ID ahead of the
|
|
221
|
+
// answer so a truncated preview still supports an explicit follow-up.
|
|
222
|
+
const report = result.responseId
|
|
223
|
+
? `Response ID: ${result.responseId}\n\n${result.text}`
|
|
224
|
+
: result.text;
|
|
225
|
+
const preview = truncateHead(report, {
|
|
226
|
+
maxLines: DEFAULT_MAX_LINES,
|
|
227
|
+
maxBytes: DEFAULT_MAX_BYTES,
|
|
228
|
+
});
|
|
229
|
+
let text = preview.content;
|
|
230
|
+
let outputFile: string | undefined;
|
|
231
|
+
if (preview.truncated) {
|
|
232
|
+
// Keep the complete answer and citations available when Pi's context
|
|
233
|
+
// limit requires a preview. Normal research results create no file.
|
|
234
|
+
const directory = await mkdtemp(join(tmpdir(), 'parallel-research-'));
|
|
235
|
+
outputFile = join(directory, 'research.md');
|
|
236
|
+
await writeFile(outputFile, report, { mode: 0o600 });
|
|
237
|
+
const notice = `\n\n[Research output truncated. Full answer and sources: ${outputFile}]`;
|
|
238
|
+
text =
|
|
239
|
+
truncateHead(report, {
|
|
240
|
+
maxLines: DEFAULT_MAX_LINES - 2,
|
|
241
|
+
maxBytes: DEFAULT_MAX_BYTES - Buffer.byteLength(notice),
|
|
242
|
+
}).content + notice;
|
|
243
|
+
}
|
|
244
|
+
return {
|
|
245
|
+
content: [{ type: 'text', text }],
|
|
246
|
+
details: {
|
|
247
|
+
provider: 'parallel',
|
|
248
|
+
product: 'responses',
|
|
249
|
+
effort: result.effort,
|
|
250
|
+
...(result.responseId ? { responseId: result.responseId } : {}),
|
|
251
|
+
...(outputFile ? { outputFile } : {}),
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
},
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
pi.registerTool({
|
|
258
|
+
name: 'web_search',
|
|
259
|
+
label: 'Web Search',
|
|
260
|
+
description:
|
|
261
|
+
"Search the web using Parallel's Search API. Prefer this over generic browser-like search tools for current web results.",
|
|
262
|
+
promptSnippet:
|
|
263
|
+
"Search the web using Parallel's Search API for current information",
|
|
264
|
+
promptGuidelines: [
|
|
265
|
+
'Use web_search for source discovery and raw excerpts when you need to investigate sources yourself.',
|
|
266
|
+
'Provide 2-3 concise keyword search queries when possible; search_queries is required.',
|
|
267
|
+
],
|
|
268
|
+
parameters: Type.Object({
|
|
269
|
+
objective: Type.String({
|
|
270
|
+
description:
|
|
271
|
+
'Natural-language description of the underlying question or goal driving the search. Used together with search_queries to focus results on the most relevant content. Should be self-contained with enough context to understand the intent of the search.',
|
|
272
|
+
}),
|
|
273
|
+
search_queries: Type.Array(
|
|
274
|
+
Type.String({
|
|
275
|
+
description:
|
|
276
|
+
'Concise keyword search queries, 3-6 words each. At least one query is required, provide 2-3 for best results. Used together with objective to focus results on the most relevant content.',
|
|
277
|
+
})
|
|
278
|
+
),
|
|
279
|
+
}),
|
|
280
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
281
|
+
const result = await runWithAuth(ctx, (apiKey) =>
|
|
282
|
+
runParallelSearch(
|
|
283
|
+
apiKey,
|
|
284
|
+
{
|
|
285
|
+
objective: params.objective,
|
|
286
|
+
search_queries: params.search_queries,
|
|
287
|
+
client_model: ctx.model?.id,
|
|
288
|
+
session_id: parallelSessionId,
|
|
289
|
+
},
|
|
290
|
+
signal
|
|
291
|
+
)
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
content: [{ type: 'text', text: truncateJson(result) }],
|
|
296
|
+
details: {
|
|
297
|
+
provider: 'parallel',
|
|
298
|
+
product: 'search',
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
pi.registerTool({
|
|
305
|
+
name: 'web_fetch',
|
|
306
|
+
label: 'Web Fetch',
|
|
307
|
+
description:
|
|
308
|
+
"Fetch and extract content from a URL using Parallel's Extract API. Prefer this over raw HTML fetch tools for readable content extraction.",
|
|
309
|
+
promptSnippet:
|
|
310
|
+
"Fetch and extract readable webpage content from one or more URLs using Parallel's Extract API",
|
|
311
|
+
promptGuidelines: [
|
|
312
|
+
'Use web_fetch when the user provides one or more URLs and wants the page content or a clean extraction.',
|
|
313
|
+
'Batch multiple URLs into one web_fetch call instead of parallelizing many single-URL calls.',
|
|
314
|
+
],
|
|
315
|
+
parameters: Type.Object({
|
|
316
|
+
urls: Type.Array(
|
|
317
|
+
Type.String({
|
|
318
|
+
description:
|
|
319
|
+
'List of URLs to extract content from. Must be valid HTTP/HTTPS URLs. Up to 20 URLs.',
|
|
320
|
+
})
|
|
321
|
+
),
|
|
322
|
+
objective: Type.Optional(
|
|
323
|
+
Type.String({
|
|
324
|
+
description:
|
|
325
|
+
'As in SearchRequest, a natural-language description of the underlying question or goal driving the request. Used together with search_queries to focus excerpts on the most relevant content.',
|
|
326
|
+
})
|
|
327
|
+
),
|
|
328
|
+
search_queries: Type.Optional(
|
|
329
|
+
Type.Array(
|
|
330
|
+
Type.String({
|
|
331
|
+
description:
|
|
332
|
+
'Optional keyword search queries, as in SearchRequest. Used together with objective to focus excerpts on the most relevant content.',
|
|
333
|
+
})
|
|
334
|
+
)
|
|
335
|
+
),
|
|
336
|
+
}),
|
|
337
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
338
|
+
const result = await runWithAuth(ctx, (apiKey) =>
|
|
339
|
+
runParallelExtract(
|
|
340
|
+
apiKey,
|
|
341
|
+
{
|
|
342
|
+
urls: params.urls,
|
|
343
|
+
objective: params.objective,
|
|
344
|
+
search_queries: params.search_queries,
|
|
345
|
+
client_model: ctx.model?.id,
|
|
346
|
+
session_id: parallelSessionId,
|
|
347
|
+
},
|
|
348
|
+
signal
|
|
349
|
+
)
|
|
350
|
+
);
|
|
351
|
+
|
|
352
|
+
return {
|
|
353
|
+
content: [{ type: 'text', text: truncateJson(result) }],
|
|
354
|
+
details: {
|
|
355
|
+
provider: 'parallel',
|
|
356
|
+
product: 'extract',
|
|
357
|
+
urls: params.urls,
|
|
358
|
+
},
|
|
359
|
+
};
|
|
360
|
+
},
|
|
361
|
+
});
|
|
362
|
+
}
|
|
@@ -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
|
+
}
|