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