@haystackeditor/cli 0.16.0 → 0.17.1
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/README.md +31 -2
- package/dist/commands/case-batch-contract.js +93 -7
- package/dist/commands/case-batch.js +327 -29
- package/dist/commands/submit.js +32 -69
- package/dist/commands/verify-precompute.js +7 -1
- package/dist/index.js +56 -20
- package/dist/schema.js +2 -2
- package/dist/triage/astra.js +202 -0
- package/dist/triage/prompts.js +145 -180
- package/dist/triage/runner.js +99 -381
- package/dist/triage/types.js +2 -2
- package/dist/types.js +2 -6
- package/dist/utils/haystack-api.js +12 -0
- package/package.json +2 -1
- package/schemas/case-batch.v1.json +54 -3
- package/schemas/submit.v1.json +4 -2
|
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import chalk from 'chalk';
|
|
8
8
|
import { NotLoggedInError, resolveAuthContext } from '../utils/auth.js';
|
|
9
9
|
import { findGitRoot, parseRemoteUrl, resolveDiffBaseRef, } from '../utils/git.js';
|
|
10
|
-
import { HaystackApiError, haystackJson } from '../utils/haystack-api.js';
|
|
10
|
+
import { HaystackApiError, haystackJson, REPO_NOT_ENTITLED_CODE, SUBSCRIPTION_REQUIRED_CODE, } from '../utils/haystack-api.js';
|
|
11
11
|
import { trackPrecomputeEvent } from '../utils/telemetry.js';
|
|
12
12
|
import { findSecretsInPatch, isSecretBearingPath } from '../utils/secret-paths.js';
|
|
13
13
|
import { startPrecomputeDelivery } from './precompute-delivery.js';
|
|
@@ -368,6 +368,12 @@ function normalModeFailure(error) {
|
|
|
368
368
|
return new PrecomputeFailure('Authentication is required. Run `haystack login` first.');
|
|
369
369
|
}
|
|
370
370
|
if (error instanceof HaystackApiError) {
|
|
371
|
+
// Entitlement denials are not authentication problems: classifyHttpError
|
|
372
|
+
// already turned the server body into its one-line message, so show it.
|
|
373
|
+
if (error.status === 403
|
|
374
|
+
&& (error.code === REPO_NOT_ENTITLED_CODE || error.code === SUBSCRIPTION_REQUIRED_CODE)) {
|
|
375
|
+
return new PrecomputeFailure(error.message);
|
|
376
|
+
}
|
|
371
377
|
if (error.status === 401 || error.status === 403) {
|
|
372
378
|
return new PrecomputeFailure('Authentication was rejected. Run `haystack login` and try again.');
|
|
373
379
|
}
|
package/dist/index.js
CHANGED
|
@@ -421,9 +421,11 @@ caseBatch
|
|
|
421
421
|
.requiredOption('--combinations <file>', 'JSON file: the combination space each case start resolves against')
|
|
422
422
|
.option('--base-world <id>', 'Existing frozen base world (frz_ plus 16 hex); omit to prepare it')
|
|
423
423
|
.option('--head-world <id>', 'Existing frozen head world (frz_ plus 16 hex); omit to prepare it')
|
|
424
|
+
.option('--driver-bundle <sha256>', 'The worlds carry a resident driver running this probe bundle; sealed cases run on it')
|
|
424
425
|
.option('--max-concurrent <n>', 'Concurrency ceiling, 1-500 (default: the case count, capped at 500)')
|
|
425
426
|
.option('--per-case-wall-ms <ms>', 'Per-case wall budget, 1000-600000 (default 120000)')
|
|
426
427
|
.option('--total-budget-ms <ms>', 'Launch budget for the batch, 10000-1800000 (default 1800000)')
|
|
428
|
+
.option('--warm-settle-ms <ms>', 'Adopt only warm clones ready at least this long, 0 to the per-case wall (default: absent = 0)')
|
|
427
429
|
.option('--idempotency-key <key>', 'Stable retry key (default: deterministic from the whole submission)')
|
|
428
430
|
.option('--source-patch <file>', 'JSON file with the precompute-captured { cacheKey, patchSha256, patchGzBase64 }')
|
|
429
431
|
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
@@ -485,6 +487,31 @@ cleanup and posts its terminal checkpoint.
|
|
|
485
487
|
const { caseBatchCancelCommand } = await import('./commands/case-batch.js');
|
|
486
488
|
return runPublicCommand(() => caseBatchCancelCommand(runId, options), options.json);
|
|
487
489
|
});
|
|
490
|
+
caseBatch
|
|
491
|
+
.command('finalize')
|
|
492
|
+
.description('Make a stranded owned case batch terminal (cancelled or incomplete) and free its world-pair lock')
|
|
493
|
+
.argument('<run-id>', 'Case batch run id (cv_ followed by 48 lowercase hex characters)')
|
|
494
|
+
.requiredOption('--repository <owner/repo>', 'Exact GitHub owner/repository name')
|
|
495
|
+
.requiredOption('--status <status>', 'Terminal status to record: cancelled or incomplete')
|
|
496
|
+
.requiredOption('--reason <text>', 'Why the batch is stranded; recorded as the batch error')
|
|
497
|
+
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
498
|
+
.option('--json', 'Versioned machine-readable finalize receipt')
|
|
499
|
+
.addHelpText('after', `
|
|
500
|
+
For a batch no owner can finish: a retired version no runner claims, a
|
|
501
|
+
checkpoint the service keeps refusing, a dead runner. Accepted only when the
|
|
502
|
+
batch was cancelled, or has gone 15 minutes without an update, and no runner
|
|
503
|
+
holds a live claim on it; otherwise refused with a typed reason. Finalize does
|
|
504
|
+
not assert cleanup: the receipt reports cleanup exactly as the batch last
|
|
505
|
+
recorded it.
|
|
506
|
+
|
|
507
|
+
Example:
|
|
508
|
+
haystack case-batch finalize cv_<48 hex> --repository owner/repo \\
|
|
509
|
+
--status cancelled --reason "v1 batch stranded after the v2 roll" --json
|
|
510
|
+
`)
|
|
511
|
+
.action(async (runId, options) => {
|
|
512
|
+
const { caseBatchFinalizeCommand } = await import('./commands/case-batch.js');
|
|
513
|
+
return runPublicCommand(() => caseBatchFinalizeCommand(runId, options), options.json);
|
|
514
|
+
});
|
|
488
515
|
caseBatch
|
|
489
516
|
.command('bundle')
|
|
490
517
|
.description('Download a case batch replay bundle, verifying every artifact digest')
|
|
@@ -492,14 +519,22 @@ caseBatch
|
|
|
492
519
|
.requiredOption('--repository <owner/repo>', 'Exact GitHub owner/repository name')
|
|
493
520
|
.requiredOption('--out <dir>', 'Directory to write the verified bundle into')
|
|
494
521
|
.option('--account <login>', 'Use a specific saved Haystack account')
|
|
522
|
+
.option('--only <path>', 'Fetch only this listed artifact, or every artifact under this directory prefix '
|
|
523
|
+
+ '(ending in /); repeatable. The manifest and its pages are always fetched.', (value, previous = []) => [...previous, value])
|
|
495
524
|
.option('--json', 'Versioned machine-readable list of verified artifacts')
|
|
496
525
|
.addHelpText('after', `
|
|
497
526
|
The manifest is fetched first and checked against the digest the service sealed
|
|
498
527
|
it under; that sealed digest is required, because the manifest is the one
|
|
499
528
|
artifact with no parent digest to check it against. Every artifact the manifest
|
|
500
|
-
lists (and every
|
|
501
|
-
|
|
502
|
-
stops the command; it never writes
|
|
529
|
+
lists (and every PNG and case records.json those pages list) is then fetched and
|
|
530
|
+
checked against the digest (and, where declared, the byte length) its listing
|
|
531
|
+
names before it is written. Any failure stops the command; it never writes
|
|
532
|
+
unverified bytes.
|
|
533
|
+
|
|
534
|
+
A batch's PNGs are uploaded after it is terminal. A PNG the service reports as
|
|
535
|
+
still uploading is not written: it is listed as pending (\`pending\` in --json)
|
|
536
|
+
and the command exits 75 once everything else is verified. Rerun it, or fetch
|
|
537
|
+
just those paths with --only.
|
|
503
538
|
`)
|
|
504
539
|
.action(async (runId, options) => {
|
|
505
540
|
const { caseBatchBundleCommand } = await import('./commands/case-batch.js');
|
|
@@ -600,37 +635,39 @@ program
|
|
|
600
635
|
.option('--no-auto-merge', 'Do not apply the auto-merge label')
|
|
601
636
|
.option('--no-wait', 'Skip waiting for analysis results')
|
|
602
637
|
.option('--json', 'Machine-readable: one JSON document on stdout (see `haystack schema submit`), progress on stderr')
|
|
603
|
-
.option('--
|
|
604
|
-
.option('--triage-timeout <seconds>', 'Wall-clock timeout per triage checker in seconds (overrides .haystack.json triage.timeoutMs)', (v) => parseInt(v, 10))
|
|
638
|
+
.option('--triage-timeout <seconds>', 'Abort a triage checker after this many seconds with no data from the model (overrides .haystack.json triage.timeoutMs)', (v) => parseInt(v, 10))
|
|
605
639
|
.addHelpText('after', `
|
|
606
640
|
This command is designed for AI coding agents to submit PRs.
|
|
607
641
|
|
|
608
|
-
1. Runs pre-PR triage (code review and rules)
|
|
642
|
+
1. Runs pre-PR triage (code review and rules) on gpt-6-astra
|
|
609
643
|
2. Pushes the current branch to origin
|
|
610
644
|
3. Creates a pull request on GitHub
|
|
611
645
|
4. Waits for Haystack analysis results (triggered via GitHub App webhook)
|
|
612
646
|
|
|
613
647
|
Pre-PR Triage:
|
|
614
|
-
Before creating the PR, haystack
|
|
615
|
-
|
|
616
|
-
•
|
|
648
|
+
Before creating the PR, haystack sends the diff to gpt-6-astra (OpenAI
|
|
649
|
+
Responses API, reasoning effort "xhigh", structured output) in parallel for:
|
|
650
|
+
• Code review bugs (logic errors, null crashes, security issues, secrets)
|
|
651
|
+
• Rule violations (from .haystack/pr-rules.yml and CLAUDE.md/AGENTS.md etc.)
|
|
652
|
+
|
|
653
|
+
Uses OPENAI_API_KEY if set, else the SSM parameter
|
|
654
|
+
/haystack/secrets/shared/prod/OPENAI_API_KEY (us-west-2) via the default
|
|
655
|
+
AWS credential chain. If a checker call fails, submit prints
|
|
656
|
+
"<checker> failed: <error>" and continues; only findings with severity
|
|
657
|
+
"error" block the PR.
|
|
617
658
|
|
|
618
659
|
Use --force to skip triage entirely.
|
|
619
660
|
Use --no-wait to skip waiting for analysis results.
|
|
620
661
|
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
Applies the same N to both.
|
|
625
|
-
• --triage-timeout <sec> Raise/lower the per-checker wall-clock timeout
|
|
626
|
-
(default: 180s).
|
|
662
|
+
• --triage-timeout <sec> Abort a checker when the model stream has been
|
|
663
|
+
silent this long (default: 300s). There is no
|
|
664
|
+
total-time cap.
|
|
627
665
|
|
|
628
|
-
Persist
|
|
666
|
+
Persist it in .haystack.json to apply per project:
|
|
629
667
|
|
|
630
668
|
{
|
|
631
669
|
"triage": {
|
|
632
|
-
"
|
|
633
|
-
"timeoutMs": 240000
|
|
670
|
+
"timeoutMs": 300000
|
|
634
671
|
}
|
|
635
672
|
}
|
|
636
673
|
|
|
@@ -674,8 +711,7 @@ Examples:
|
|
|
674
711
|
haystack submit --review # ⚠ Blocks auto-merge, needs human approval
|
|
675
712
|
haystack submit --review octocat # ⚠ Blocks auto-merge, requests review from octocat
|
|
676
713
|
haystack submit --account octocat # Use a specific saved Haystack account for this submit
|
|
677
|
-
haystack submit --
|
|
678
|
-
haystack submit --triage-timeout 300 # Raise triage wall-clock to 5 minutes
|
|
714
|
+
haystack submit --triage-timeout 600 # Allow 10 minutes of model silence per checker
|
|
679
715
|
`)
|
|
680
716
|
.action(async (options, command) => {
|
|
681
717
|
// Resolve --auto-merge / --auto-fix defaults from .haystack.json when not
|
package/dist/schema.js
CHANGED
|
@@ -16,10 +16,10 @@ export const SCHEMA_VERSIONS = {
|
|
|
16
16
|
'pr-status': '1.0.0',
|
|
17
17
|
inbox: '1.0.0',
|
|
18
18
|
ask: '1.0.0',
|
|
19
|
-
submit: '1.0.
|
|
19
|
+
submit: '1.0.1',
|
|
20
20
|
action: '1.0.0',
|
|
21
21
|
'cloud-verifier': '1.0.0',
|
|
22
|
-
'case-batch': '1.0.
|
|
22
|
+
'case-batch': '1.0.1',
|
|
23
23
|
error: '1.0.0',
|
|
24
24
|
};
|
|
25
25
|
/** Wrap a payload with the `schema_version` envelope. */
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One structured Responses API call to gpt-6-astra, the pre-PR reviewer.
|
|
3
|
+
*
|
|
4
|
+
* Streams the response so a silent connection can be told apart from a model
|
|
5
|
+
* that is still reasoning: the call is aborted only when no data has arrived
|
|
6
|
+
* for `stallMs`, never on total elapsed time. Uses node:https rather than
|
|
7
|
+
* fetch because fetch's transport imposes its own 300s body timeout, which
|
|
8
|
+
* would override a larger stall limit. Any failure throws; the runner reports
|
|
9
|
+
* it and submit continues without that checker.
|
|
10
|
+
*/
|
|
11
|
+
import { request as httpsRequest } from 'node:https';
|
|
12
|
+
export const TRIAGE_MODEL = 'gpt-6-astra';
|
|
13
|
+
/**
|
|
14
|
+
* Reasoning effort for gpt-6-astra (the API accepts none|minimal|low|medium|high|xhigh|max). xhigh, not max:
|
|
15
|
+
* max-effort reviews ran too long to wait on (owner's call, 2026-09-25).
|
|
16
|
+
*/
|
|
17
|
+
export const TRIAGE_REASONING_EFFORT = 'xhigh';
|
|
18
|
+
const RESPONSES_URL = 'https://api.openai.com/v1/responses';
|
|
19
|
+
/** SSM parameter holding the shared OpenAI key, read when OPENAI_API_KEY is unset. */
|
|
20
|
+
export const OPENAI_KEY_PARAMETER = '/haystack/secrets/shared/prod/OPENAI_API_KEY';
|
|
21
|
+
const OPENAI_KEY_PARAMETER_REGION = 'us-west-2';
|
|
22
|
+
export const NO_OPENAI_KEY_MESSAGE = `no OpenAI key (set OPENAI_API_KEY or AWS access to ${OPENAI_KEY_PARAMETER})`;
|
|
23
|
+
/**
|
|
24
|
+
* Default reader: AWS SDK v3 SSM with the default credential chain
|
|
25
|
+
* (AWS_PROFILE, env keys, SSO, instance role). Imported lazily so the SDK is
|
|
26
|
+
* off the startup path of every other command.
|
|
27
|
+
*/
|
|
28
|
+
const readSsmParameter = async (name, region) => {
|
|
29
|
+
const { SSMClient, GetParameterCommand } = await import('@aws-sdk/client-ssm');
|
|
30
|
+
const client = new SSMClient({ region, maxAttempts: 2 });
|
|
31
|
+
try {
|
|
32
|
+
const out = await client.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
|
|
33
|
+
return out.Parameter?.Value;
|
|
34
|
+
}
|
|
35
|
+
finally {
|
|
36
|
+
client.destroy();
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the OpenAI key: OPENAI_API_KEY if set, else the shared SSM
|
|
41
|
+
* parameter. Throws NO_OPENAI_KEY_MESSAGE (with the AWS cause appended) when
|
|
42
|
+
* neither yields a key. The key itself is never logged or put in an error.
|
|
43
|
+
*/
|
|
44
|
+
export async function resolveOpenAiKey(env = process.env, readParameter = readSsmParameter) {
|
|
45
|
+
const fromEnv = env.OPENAI_API_KEY?.trim();
|
|
46
|
+
if (fromEnv)
|
|
47
|
+
return fromEnv;
|
|
48
|
+
let cause;
|
|
49
|
+
try {
|
|
50
|
+
const fromSsm = (await readParameter(OPENAI_KEY_PARAMETER, OPENAI_KEY_PARAMETER_REGION))?.trim();
|
|
51
|
+
if (fromSsm)
|
|
52
|
+
return fromSsm;
|
|
53
|
+
cause = 'parameter is empty';
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
cause = (err instanceof Error ? `${err.name}: ${err.message}` : String(err)).slice(0, 200);
|
|
57
|
+
}
|
|
58
|
+
throw new Error(`${NO_OPENAI_KEY_MESSAGE}; AWS lookup: ${cause}`);
|
|
59
|
+
}
|
|
60
|
+
let apiKeyPromise;
|
|
61
|
+
/** One resolution per process, shared by the parallel checkers. */
|
|
62
|
+
function readApiKey() {
|
|
63
|
+
apiKeyPromise ??= resolveOpenAiKey();
|
|
64
|
+
return apiKeyPromise;
|
|
65
|
+
}
|
|
66
|
+
/** The API's own error message from a non-2xx body, else the body's first 300 chars. */
|
|
67
|
+
function apiErrorMessage(body) {
|
|
68
|
+
try {
|
|
69
|
+
const message = JSON.parse(body).error?.message;
|
|
70
|
+
if (typeof message === 'string' && message)
|
|
71
|
+
return message;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Not JSON (proxy or gateway page); report the raw text below.
|
|
75
|
+
}
|
|
76
|
+
return body.slice(0, 300);
|
|
77
|
+
}
|
|
78
|
+
/** Pull the final JSON text out of a completed response. */
|
|
79
|
+
function outputText(response) {
|
|
80
|
+
const parts = (response.output ?? [])
|
|
81
|
+
.filter(item => item.type === 'message')
|
|
82
|
+
.flatMap(item => item.content ?? []);
|
|
83
|
+
const refusal = parts.find(part => part.type === 'refusal');
|
|
84
|
+
if (refusal)
|
|
85
|
+
throw new Error(`${TRIAGE_MODEL} refused: ${refusal.refusal ?? '(no reason)'}`);
|
|
86
|
+
const text = parts.filter(part => part.type === 'output_text').map(part => part.text ?? '').join('');
|
|
87
|
+
if (!text)
|
|
88
|
+
throw new Error(`${TRIAGE_MODEL} returned no output text`);
|
|
89
|
+
return text;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Handle one SSE event block. Returns the parsed reply on response.completed,
|
|
93
|
+
* throws on a terminal failure event, and returns undefined otherwise.
|
|
94
|
+
*/
|
|
95
|
+
function handleEventBlock(block) {
|
|
96
|
+
const data = block
|
|
97
|
+
.split('\n')
|
|
98
|
+
.filter(line => line.startsWith('data:'))
|
|
99
|
+
.map(line => line.slice(5).trim())
|
|
100
|
+
.join('');
|
|
101
|
+
if (!data || data === '[DONE]')
|
|
102
|
+
return undefined;
|
|
103
|
+
const event = JSON.parse(data);
|
|
104
|
+
switch (event.type) {
|
|
105
|
+
case 'response.completed':
|
|
106
|
+
return { reply: JSON.parse(outputText(event.response ?? {})) };
|
|
107
|
+
case 'response.incomplete':
|
|
108
|
+
throw new Error(`${TRIAGE_MODEL} response incomplete: ${event.response?.incomplete_details?.reason ?? 'unknown reason'}`);
|
|
109
|
+
case 'response.failed':
|
|
110
|
+
throw new Error(`${TRIAGE_MODEL} response failed: ${event.response?.error?.message ?? 'unknown error'}`);
|
|
111
|
+
case 'error':
|
|
112
|
+
throw new Error(`OpenAI stream error: ${event.message ?? event.error?.message ?? data.slice(0, 300)}`);
|
|
113
|
+
default:
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** Run one structured call and return the parsed JSON object. */
|
|
118
|
+
export async function callAstra(request) {
|
|
119
|
+
const apiKey = await readApiKey();
|
|
120
|
+
const body = JSON.stringify({
|
|
121
|
+
model: TRIAGE_MODEL,
|
|
122
|
+
reasoning: { effort: TRIAGE_REASONING_EFFORT },
|
|
123
|
+
store: false,
|
|
124
|
+
stream: true,
|
|
125
|
+
instructions: request.instructions,
|
|
126
|
+
input: request.input,
|
|
127
|
+
text: {
|
|
128
|
+
format: { type: 'json_schema', name: request.schemaName, strict: true, schema: request.schema },
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
return new Promise((resolve, reject) => {
|
|
132
|
+
let settled = false;
|
|
133
|
+
let stallTimer;
|
|
134
|
+
const finish = (err, reply) => {
|
|
135
|
+
if (settled)
|
|
136
|
+
return;
|
|
137
|
+
settled = true;
|
|
138
|
+
clearTimeout(stallTimer);
|
|
139
|
+
req.destroy();
|
|
140
|
+
if (err)
|
|
141
|
+
reject(err);
|
|
142
|
+
else
|
|
143
|
+
resolve(reply);
|
|
144
|
+
};
|
|
145
|
+
const armStallTimer = () => {
|
|
146
|
+
// A chunk that lands after the call settled must not start a new timer:
|
|
147
|
+
// nothing would clear it and it would hold the process open.
|
|
148
|
+
if (settled)
|
|
149
|
+
return;
|
|
150
|
+
clearTimeout(stallTimer);
|
|
151
|
+
stallTimer = setTimeout(() => {
|
|
152
|
+
finish(new Error(`no data from ${TRIAGE_MODEL} for ${Math.round(request.stallMs / 1000)}s; aborted`));
|
|
153
|
+
}, request.stallMs);
|
|
154
|
+
};
|
|
155
|
+
const req = httpsRequest(RESPONSES_URL, {
|
|
156
|
+
method: 'POST',
|
|
157
|
+
headers: {
|
|
158
|
+
Authorization: `Bearer ${apiKey}`,
|
|
159
|
+
'Content-Type': 'application/json',
|
|
160
|
+
'Content-Length': Buffer.byteLength(body),
|
|
161
|
+
Accept: 'text/event-stream',
|
|
162
|
+
},
|
|
163
|
+
}, (res) => {
|
|
164
|
+
res.setEncoding('utf8');
|
|
165
|
+
const status = res.statusCode ?? 0;
|
|
166
|
+
let buffer = '';
|
|
167
|
+
res.on('data', (chunk) => {
|
|
168
|
+
armStallTimer();
|
|
169
|
+
buffer += chunk;
|
|
170
|
+
if (status < 200 || status >= 300)
|
|
171
|
+
return;
|
|
172
|
+
// SSE allows CRLF, LF or CR line endings; normalize before splitting events.
|
|
173
|
+
buffer = buffer.replace(/\r\n?/g, '\n');
|
|
174
|
+
let boundary;
|
|
175
|
+
while ((boundary = buffer.indexOf('\n\n')) >= 0) {
|
|
176
|
+
const block = buffer.slice(0, boundary);
|
|
177
|
+
buffer = buffer.slice(boundary + 2);
|
|
178
|
+
try {
|
|
179
|
+
const outcome = handleEventBlock(block);
|
|
180
|
+
if (outcome)
|
|
181
|
+
return finish(null, outcome.reply);
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
return finish(err instanceof Error ? err : new Error(String(err)));
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
res.on('end', () => {
|
|
189
|
+
if (status < 200 || status >= 300) {
|
|
190
|
+
finish(new Error(`OpenAI ${status}: ${apiErrorMessage(buffer)}`));
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
finish(new Error('OpenAI stream ended without a completed response'));
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
res.on('error', err => finish(err));
|
|
197
|
+
});
|
|
198
|
+
req.on('error', err => finish(err));
|
|
199
|
+
armStallTimer();
|
|
200
|
+
req.end(body);
|
|
201
|
+
});
|
|
202
|
+
}
|