fix(cli,core): make qwen batch and --batch actually run

Three defects the unit tests could not see, all found by driving the real
CLI process against a fake DashScope server (added here as
docs/verification/batch-api/).

1. `--batch` never ran at all. `client.files.create` throws "The provided
   fetch function does not support file uploads with the current global
   FormData class" before a request leaves the process: every Node run
   installs a custom fetch on the client (undici's, pinned to the
   timeout-free dispatcher in runtimeFetchOptions.ts), and the SDK's
   multipart probe rejects any such client. Upload through the global
   fetch instead — the same route `qwen batch submit` already takes.

2. `batch` was missing from the subcommand exit list in parseArguments, so
   a handler fell through to the main flow: every subcommand exited 1 with
   "No input provided via stdin" (a TTY would have entered the UI), and the
   memory relaunch below re-parsed argv in the child, running the handler a
   second time — `qwen batch submit` created two batch jobs and billed
   both, returning only the second id.

3. A settled-but-failed batch reported `Batch <id> failed` and nothing
   else: the reason lives in the error file, which the cleanup then
   deleted. Read it before throwing.

Verified with docs/verification/batch-api/regression.sh (26 assertions,
no network): one batch per submit, exit 0 for all four subcommands,
--batch through a two-hop tool call, the provider's reason on failure,
refusal to fetch an unsettled batch, and SIGINT cancelling server-side.
This commit is contained in:
易良 2026-09-18 14:11:26 +09:00
parent 439d0ce398
commit 004b7b9fe8
7 changed files with 490 additions and 17 deletions

View file

@ -75,6 +75,24 @@ P_OUT=4 CACHE_RATIO=0.2 node docs/verification/batch-api/02-cache.mjs
应看到 stderr 打出 `[batch] submitted batch_xxx`,等待后正常完成一轮工具调用。
TUI 下 `--batch` 会被 `.check()` 拒绝;QWEN_OAUTH 下会在第一次请求时报错。
## 本地回归(不花钱、不联网)
`fake-dashscope.mjs` 是一个假的百炼兼容服务(`/files`、`/batches`、`/batches/:id/cancel`、
`/files/:id/content`、`/chat/completions`),按场景推进 batch 状态并记录每条请求;
`regression.sh` 驱动**真实 CLI 进程**跑完 happy / tools / failed / slow / stuck 五个场景,
断言退出码、stdout 与请求序列:
```sh
bash docs/verification/batch-api/regression.sh # 约 5 分钟,26 条断言
```
覆盖:submit 只创建一个 batch(不被内存重启重复提交)、四个子命令退出码为 0 且不掉进主流程、
鉴权门禁、`--batch` 对 `-i` 的拒绝、`--batch` 两跳工具调用端到端、失败时透出服务端原因且不重试、
未 settle 时拒绝 fetch、SIGINT 触发服务端 cancel。
它证明的是 qwen-code 这一侧的进程行为,**不能**替代上面三个线上探针——百炼是否接受这些请求、
工具调用能否穿过 batch body、batch 内是否命中缓存,只有真打接口才知道。
## 判定
| 01 | 02 | 结论 |

View file

@ -0,0 +1,239 @@
/**
* Fake DashScope/OpenAI-compatible server for L1 verification of the
* `qwen batch` command and `--batch` mode. Records every request so a run can
* be asserted on the call sequence (e.g. how many batches were created).
*
* Scenarios (env SCENARIO):
* happy - one batch, completes on first poll, plain text output
* tools - first batch returns tool_calls, second returns text
* failed - every batch settles as `failed`
* slow - batch stays in_progress for SLOW_SECONDS, then completes
* stuck - batch never completes (for abort/cancel tests)
*/
import http from 'node:http';
import fs from 'node:fs';
const PORT = Number(process.env.PORT || 8899);
const SCENARIO = process.env.SCENARIO || 'happy';
const SLOW_SECONDS = Number(process.env.SLOW_SECONDS || 600);
const LOG = process.env.LOG || '/tmp/fake-dashscope.log';
const MODEL = process.env.FAKE_MODEL || 'qwen-plus';
fs.writeFileSync(LOG, '');
let fileSeq = 0;
let batchSeq = 0;
const files = new Map(); // id -> {content, purpose, name}
const batches = new Map(); // id -> job
const now = () => Math.floor(Date.now() / 1000);
const log = (entry) =>
fs.appendFileSync(LOG, JSON.stringify({ t: Date.now(), ...entry }) + '\n');
/** Pull the JSONL request lines out of a multipart upload body. */
function extractJsonl(raw) {
return raw
.split('\n')
.map((l) => l.trim())
.filter((l) => l.startsWith('{') && l.includes('"custom_id"'));
}
function completionBody(kind, id) {
const base = {
id: `chatcmpl-${id}`,
object: 'chat.completion',
created: now(),
model: MODEL,
usage: {
prompt_tokens: 120,
completion_tokens: 8,
total_tokens: 128,
prompt_tokens_details: { cached_tokens: 64 },
},
};
if (kind === 'tool_call') {
return {
...base,
choices: [
{
index: 0,
finish_reason: 'tool_calls',
message: {
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_batch_1',
type: 'function',
function: {
name: 'list_directory',
arguments: JSON.stringify({ path: '.' }),
},
},
],
},
},
],
};
}
return {
...base,
choices: [
{
index: 0,
finish_reason: 'stop',
message: { role: 'assistant', content: 'BATCH_OK' },
},
],
};
}
/** Advance a job's status for the current scenario and return it. */
function pollJob(job) {
const elapsed = now() - job.created_at;
if (SCENARIO === 'stuck') {
job.status = 'in_progress';
job.in_progress_at ??= now();
return job;
}
if (SCENARIO === 'slow' && elapsed < SLOW_SECONDS) {
job.status = 'in_progress';
job.in_progress_at ??= now();
return job;
}
if (SCENARIO === 'failed') {
job.status = 'failed';
job.completed_at = now();
job.request_counts = { total: 1, completed: 0, failed: 1 };
const errId = `file-err-${++fileSeq}`;
files.set(errId, {
content:
JSON.stringify({
custom_id: 'turn',
error: { message: 'fake: model unavailable in batch' },
}) + '\n',
});
job.error_file_id = errId;
return job;
}
// happy / tools
job.status = 'completed';
job.in_progress_at ??= job.created_at + 1;
job.completed_at = now();
const lines = job.lines.map((line, i) => {
const kind =
SCENARIO === 'tools' && job.seq === 1 && i === 0 ? 'tool_call' : 'text';
return JSON.stringify({
custom_id: JSON.parse(line).custom_id ?? String(i),
response: { status_code: 200, body: completionBody(kind, `${job.id}-${i}`) },
});
});
const outId = `file-out-${++fileSeq}`;
files.set(outId, { content: lines.join('\n') + '\n' });
job.output_file_id = outId;
job.request_counts = { total: job.lines.length, completed: job.lines.length, failed: 0 };
return job;
}
function send(res, code, body) {
const payload = typeof body === 'string' ? body : JSON.stringify(body);
res.writeHead(code, { 'Content-Type': 'application/json' });
res.end(payload);
}
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString('utf8');
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
const p = url.pathname.replace(/^\/v1/, '');
log({ method: req.method, path: p, bytes: raw.length });
// --- files ---
if (p === '/files' && req.method === 'POST') {
const id = `file-in-${++fileSeq}`;
const lines = extractJsonl(raw);
files.set(id, { content: lines.join('\n'), lines });
log({ event: 'upload', id, lineCount: lines.length, lines });
return send(res, 200, { id, object: 'file', purpose: 'batch', bytes: raw.length });
}
let m = p.match(/^\/files\/([^/]+)\/content$/);
if (m && req.method === 'GET') {
const f = files.get(m[1]);
if (!f) return send(res, 404, { error: { message: 'no such file' } });
res.writeHead(200, { 'Content-Type': 'application/jsonl' });
return res.end(f.content);
}
m = p.match(/^\/files\/([^/]+)$/);
if (m && req.method === 'DELETE') {
files.delete(m[1]);
log({ event: 'file_deleted', id: m[1] });
return send(res, 200, { id: m[1], object: 'file', deleted: true });
}
// --- batches ---
if (p === '/batches' && req.method === 'POST') {
const body = JSON.parse(raw || '{}');
const id = `batch_fake_${++batchSeq}`;
const input = files.get(body.input_file_id);
const job = {
id,
seq: batchSeq,
object: 'batch',
status: 'validating',
endpoint: body.endpoint,
completion_window: body.completion_window,
input_file_id: body.input_file_id,
created_at: now(),
expires_at: now() + 86400,
request_counts: { total: input?.lines?.length ?? 1, completed: 0, failed: 0 },
lines: input?.lines ?? ['{"custom_id":"turn"}'],
};
batches.set(id, job);
log({ event: 'batch_created', id, endpoint: body.endpoint, window: body.completion_window });
return send(res, 200, job);
}
m = p.match(/^\/batches\/([^/]+)\/cancel$/);
if (m && req.method === 'POST') {
const job = batches.get(m[1]);
if (!job) return send(res, 404, { error: { message: 'no such batch' } });
job.status = 'cancelled';
job.completed_at = now();
log({ event: 'batch_cancelled', id: job.id });
return send(res, 200, job);
}
m = p.match(/^\/batches\/([^/]+)$/);
if (m && req.method === 'GET') {
const job = batches.get(m[1]);
if (!job) return send(res, 404, { error: { message: 'no such batch' } });
return send(res, 200, pollJob(job));
}
// --- realtime chat (side calls) ---
if (p === '/chat/completions' && req.method === 'POST') {
const body = JSON.parse(raw || '{}');
log({ event: 'realtime_chat', stream: !!body.stream, model: body.model });
const completion = completionBody('text', `rt-${Date.now()}`);
if (!body.stream) return send(res, 200, completion);
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
const chunk = {
id: completion.id,
object: 'chat.completion.chunk',
created: completion.created,
model: completion.model,
choices: [{ index: 0, delta: { role: 'assistant', content: 'REALTIME_OK' }, finish_reason: 'stop' }],
usage: completion.usage,
};
res.write(`data: ${JSON.stringify(chunk)}\n\n`);
res.write('data: [DONE]\n\n');
return res.end();
}
log({ event: 'unhandled', method: req.method, path: p });
send(res, 404, { error: { message: `fake server: no route ${req.method} ${p}` } });
});
});
server.listen(PORT, '127.0.0.1', () => {
process.stdout.write(`fake-dashscope listening on ${PORT} scenario=${SCENARIO} log=${LOG}\n`);
});

View file

@ -0,0 +1,91 @@
#!/bin/bash
# End-to-end regression for `qwen batch` and `--batch` against the fake
# DashScope server in this directory. No network, no API key, no cost.
#
# bash docs/verification/batch-api/regression.sh
#
# It drives the real CLI (`npm run dev`) through five scenarios and asserts on
# exit codes, stdout, and the request log the fake server records. What it
# proves is process behaviour; the provider's own protocol still needs the
# 00/01/02 probes in this directory.
set -u
HERE=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd "$HERE/../../.." && pwd)
SCR=${SCR:-/tmp/batch-regression}
FAKE=$HERE/fake-dashscope.mjs
rm -rf $SCR; mkdir -p $SCR/home $SCR/home-oauth/.qwen $SCR/work $SCR/out
printf '%s\n' '{"messages":[{"role":"user","content":"Reply with OK."}],"enable_thinking":false}' \
'{"custom_id":"second","body":{"messages":[{"role":"user","content":"again"}]}}' > $SCR/work/in.jsonl
echo '{"security":{"auth":{"selectedType":"qwen-oauth"}}}' > $SCR/home-oauth/.qwen/settings.json
start() { SCENARIO=$1 PORT=$2 LOG=$SCR/$1.log ${3:+SLOW_SECONDS=$3} nohup node $FAKE > $SCR/s-$1.out 2>&1 & sleep 1; }
start happy 8899; start tools 8900; start failed 8901; start stuck 8902
cd $ROOT
export HOME=$SCR/home OPENAI_API_KEY=sk-fake OPENAI_MODEL=qwen-plus
PASS=0; FAIL=0
check() { # name expected actual
if [ "$2" = "$3" ]; then echo "PASS $1"; PASS=$((PASS+1)); else echo "FAIL $1 (expected '$2', got '$3')"; FAIL=$((FAIL+1)); fi
}
contains() { grep -qF "$2" "$3" && { echo "PASS $1"; PASS=$((PASS+1)); } || { echo "FAIL $1 (missing '$2')"; FAIL=$((FAIL+1)); }; }
absent() { grep -qF "$2" "$3" && { echo "FAIL $1 (unexpected '$2')"; FAIL=$((FAIL+1)); } || { echo "PASS $1"; PASS=$((PASS+1)); }; }
run() { timeout 400 npm run dev --silent -- "$@" > $SCR/out/o.txt 2> $SCR/out/e.txt; echo $?; }
echo "--- R1 submit (default env, relaunch enabled)"
export OPENAI_BASE_URL=http://127.0.0.1:8899/v1
rc=$(run batch submit $SCR/work/in.jsonl)
check "R1 exit code 0" 0 "$rc"
check "R1 exactly one batch created" 1 "$(grep -c batch_created $SCR/happy.log)"
check "R1 one id on stdout" 1 "$(wc -l < $SCR/out/o.txt)"
absent "R1 no fall-through to main flow" "No input provided via stdin" $SCR/out/e.txt
ID=$(head -1 $SCR/out/o.txt)
echo "--- R2 status / fetch / cancel"
rc=$(run batch status $ID); check "R2 status exit 0" 0 "$rc"; contains "R2 status line" "completed" $SCR/out/o.txt
rc=$(run batch fetch $ID --out $SCR/out --delete); check "R2 fetch exit 0" 0 "$rc"
[ -f $SCR/out/$ID.output.jsonl ] && { echo "PASS R2 output file written"; PASS=$((PASS+1)); } || { echo "FAIL R2 output file"; FAIL=$((FAIL+1)); }
check "R2 remote files deleted (input+output)" 2 "$(grep -c file_deleted $SCR/happy.log)"
rc=$(run batch cancel $ID); check "R2 cancel exit 0" 0 "$rc"
echo "--- R3 auth gate under qwen-oauth"
rc=$(HOME=$SCR/home-oauth timeout 400 npm run dev --silent -- batch status $ID > $SCR/out/o.txt 2> $SCR/out/e.txt; echo $?)
check "R3 exit 1" 1 "$rc"; contains "R3 auth message" "needs an API key" $SCR/out/e.txt
absent "R3 no fall-through" "No input provided via stdin" $SCR/out/e.txt
echo "--- R4 --batch with -i"
rc=$(run --batch -i hello); check "R4 rejected" 1 "$rc"
contains "R4 message" "only available in non-interactive runs" $SCR/out/e.txt
echo "--- R5 --batch end to end (tools)"
export OPENAI_BASE_URL=http://127.0.0.1:8900/v1
rc=$(run -p "list the files here and summarize" --batch --approval-mode yolo)
check "R5 exit 0" 0 "$rc"; contains "R5 model output" "BATCH_OK" $SCR/out/o.txt
check "R5 one batch per hop (2 hops)" 2 "$(grep -c batch_created $SCR/tools.log)"
check "R5 all files cleaned up" 4 "$(grep -c file_deleted $SCR/tools.log)"
echo "--- R6 failed batch surfaces the provider reason"
export OPENAI_BASE_URL=http://127.0.0.1:8901/v1
rc=$(run -p "say hi" --batch); check "R6 exit 1" 1 "$rc"
contains "R6 reason from error file" "model unavailable in batch" $SCR/out/e.txt
check "R6 no retry storm" 1 "$(grep -c batch_created $SCR/failed.log)"
echo "--- R7 fetch before settle + SIGINT cancels"
export OPENAI_BASE_URL=http://127.0.0.1:8902/v1
rc=$(run batch submit $SCR/work/in.jsonl --window 7d); SID=$(head -1 $SCR/out/o.txt)
check "R7 submit exit 0" 0 "$rc"
rc=$(run batch fetch $SID); check "R7 fetch refused" 1 "$rc"
contains "R7 refusal message" "results are only available once the batch settles" $SCR/out/e.txt
timeout 400 npm run dev --silent -- -p "say hi" --batch > $SCR/out/o7.txt 2> $SCR/out/e7.txt &
NPMPID=$!
sleep 45
kids() { for p in $(pgrep -P $1); do echo $p; kids $p; done; }
for p in $NPMPID $(kids $NPMPID); do kill -INT $p 2>/dev/null; done
sleep 10
contains "R7 SIGINT cancels server-side" '"event":"batch_cancelled"' $SCR/stuck.log
for port in 8899 8900 8901 8902; do
pid=$(ss -ltnp "sport = :$port" 2>/dev/null | grep -o 'pid=[0-9]*' | head -1 | cut -d= -f2)
[ -n "$pid" ] && kill $pid
done
echo; echo "=== $PASS passed, $FAIL failed ==="
exit $((FAIL > 0))

View file

@ -896,6 +896,20 @@ describe('parseArguments', () => {
expect(argv.batch).toBe(true);
});
it('exits after a `batch` subcommand instead of falling through to the main flow', async () => {
// Falling through would reach the memory relaunch, whose child parses argv
// again and would submit (and bill) a second batch job.
process.argv = ['node', 'script.js', 'batch', 'status', 'batch_x'];
const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => {
throw new Error('process.exit called');
});
await expect(parseArguments()).rejects.toThrow('process.exit called');
mockExit.mockRestore();
});
it('should reject --json-schema with no prompt source when stdin is a TTY', async () => {
// True interactive invocation with no prompt anywhere → fail fast.
process.argv = ['node', 'script.js', '--json-schema', '{"type":"object"}'];

View file

@ -916,13 +916,17 @@ export async function parseArguments(): Promise<CliArgs> {
result._[0] === 'review' ||
result._[0] === 'sessions' ||
result._[0] === 'board' ||
result._[0] === 'batch' ||
result._[0] === 'update')
) {
// Note: `serve` is intentionally NOT in this list. Its handler blocks
// forever (after the listener is up); SIGINT/SIGTERM in runQwenServe
// drives shutdown. Hitting `process.exit(0)` here would kill the daemon.
// MCP/Extensions/Auth/Hooks/Channel/Review commands handle their own
// execution and exit. Returning here would let the main interactive
// MCP/Extensions/Auth/Hooks/Channel/Review/Batch commands handle their own
// execution and exit. `batch` must be here for a second reason: the main
// flow below relaunches the process for a larger heap, and a second parse
// would run the subcommand handler again — submitting (and billing) a
// duplicate batch job whose id the user never sees. Returning here would let the main interactive
// flow run, which would prompt for stdin input despite the user
// having already invoked a subcommand.
process.exit(process.exitCode ?? 0);

View file

@ -46,8 +46,9 @@ const outputLine = (body: unknown, status_code = 200) =>
function mockClient() {
return {
baseURL: 'https://dashscope.test/compatible-mode/v1',
apiKey: 'sk-test',
files: {
create: vi.fn().mockResolvedValue({ id: 'file-in' }),
content: vi.fn(),
delete: vi.fn().mockResolvedValue({}),
},
@ -68,11 +69,21 @@ const request = {
describe('runBatchCompletion', () => {
let client: ReturnType<typeof mockClient>;
// The input file goes up through the global fetch, not the SDK: the SDK
// refuses multipart for a client carrying a custom fetch (see batch.ts).
let uploadFetch: ReturnType<typeof vi.fn>;
beforeEach(() => {
client = mockClient();
uploadFetch = vi
.fn()
.mockResolvedValue(new Response(JSON.stringify({ id: 'file-in' })));
vi.stubGlobal('fetch', uploadFetch);
vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
});
afterEach(() => vi.restoreAllMocks());
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it('uploads the request without stream fields, polls, and returns the body', async () => {
client.batches.create.mockResolvedValue({ id: 'b1', status: 'validating' });
@ -93,9 +104,17 @@ describe('runBatchCompletion', () => {
);
expect(result).toEqual(completion);
const upload = client.files.create.mock.calls[0][0];
expect(upload.purpose).toBe('batch');
const line = JSON.parse((await upload.file.text()).trim());
const [uploadUrl, uploadInit] = uploadFetch.mock.calls[0] as [
string,
RequestInit,
];
expect(uploadUrl).toBe('https://dashscope.test/compatible-mode/v1/files');
expect(
(uploadInit.headers as Record<string, string>)['Authorization'],
).toBe('Bearer sk-test');
const form = uploadInit.body as FormData;
expect(form.get('purpose')).toBe('batch');
const line = JSON.parse((await (form.get('file') as File).text()).trim());
expect(line).toEqual({
custom_id: 'turn',
method: 'POST',
@ -138,12 +157,45 @@ describe('runBatchCompletion', () => {
expect(client.files.delete).toHaveBeenCalledWith('file-err');
});
it('rejects a batch that settled without completing', async () => {
client.batches.create.mockResolvedValue({ id: 'b3', status: 'expired' });
it('rejects a batch that settled without completing, with the reason from the error file', async () => {
client.batches.create.mockResolvedValue({
id: 'b3',
status: 'failed',
error_file_id: 'file-err',
});
client.files.content.mockResolvedValue(
new Response(
JSON.stringify({
custom_id: 'turn',
error: { message: 'model unavailable in batch' },
}) + '\n',
),
);
await expect(
runBatchCompletion(client as unknown as OpenAI, request, undefined, 0),
).rejects.toThrow('Batch b3 expired');
).rejects.toThrow('Batch b3 failed: model unavailable in batch');
expect(client.batches.cancel).not.toHaveBeenCalled();
// Read before the cleanup that deletes it.
expect(client.files.delete).toHaveBeenCalledWith('file-err');
});
it('reports a settled batch with no error file by status alone', async () => {
client.batches.create.mockResolvedValue({ id: 'b3b', status: 'expired' });
await expect(
runBatchCompletion(client as unknown as OpenAI, request, undefined, 0),
).rejects.toThrow('Batch b3b expired');
});
it('surfaces an upload failure with the server response', async () => {
uploadFetch.mockResolvedValue(
new Response('{"error":{"message":"bad purpose"}}', { status: 400 }),
);
await expect(
runBatchCompletion(client as unknown as OpenAI, request, undefined, 0),
).rejects.toThrow(
'POST /files -> HTTP 400: {"error":{"message":"bad purpose"}}',
);
expect(client.batches.create).not.toHaveBeenCalled();
});
it('cancels the batch and throws an AbortError when aborted while waiting', async () => {

View file

@ -49,6 +49,59 @@ async function readLines(
.map((l) => JSON.parse(l) as BatchOutputLine);
}
/**
* First error message in a batch's error file, or undefined when there is no
* error file or it cannot be read — a download failure must not mask the
* batch status the caller is about to report.
*/
async function failureDetail(
client: OpenAI,
errorFileId: string | null | undefined,
): Promise<string | undefined> {
try {
const [failure] = await readLines(client, errorFileId);
return (
failure?.error?.message ??
(failure?.response?.body as { error?: { message?: string } } | undefined)
?.error?.message
);
} catch {
return undefined;
}
}
/**
* Upload the single-line JSONL input file and return the created file id.
*
* Not `client.files.create`: the SDK rejects multipart whenever the client
* carries a custom `fetch`, and every Node run installs one (undici's fetch,
* pinned to the timeout-free dispatcher in runtimeFetchOptions.ts). The SDK
* probes support by serializing a global `FormData` through that fetch's
* `Response` class, gets back "[object FormData]", and throws "The provided
* fetch function does not support file uploads". The global fetch used here
* takes the same route `qwen batch submit` already uses.
*/
async function uploadInputFile(
client: OpenAI,
jsonl: string,
signal?: AbortSignal,
): Promise<{ id: string }> {
const form = new FormData();
form.append('purpose', 'batch');
form.append('file', new File([jsonl], 'turn.jsonl'));
const res = await fetch(`${client.baseURL.replace(/\/+$/, '')}/files`, {
method: 'POST',
headers: { Authorization: `Bearer ${client.apiKey}` },
body: form,
signal,
});
if (!res.ok) {
const detail = (await res.text()).slice(0, 500);
throw new Error(`POST /files -> HTTP ${res.status}: ${detail}`);
}
return (await res.json()) as { id: string };
}
/**
* Run one chat-completions request through the OpenAI-compatible Batch API:
* upload a single-line JSONL, create the batch, poll until it settles, read
@ -70,10 +123,7 @@ export async function runBatchCompletion(
url: '/v1/chat/completions',
body,
});
const file = await client.files.create(
{ file: new File([line + '\n'], 'turn.jsonl'), purpose: 'batch' },
{ signal },
);
const file = await uploadInputFile(client, line + '\n', signal);
const fileIds = [file.id];
let batch = await client.batches.create(
{
@ -100,15 +150,20 @@ export async function runBatchCompletion(
if (batch.output_file_id) fileIds.push(batch.output_file_id);
if (batch.error_file_id) fileIds.push(batch.error_file_id);
if (batch.status !== 'completed') {
throw new Error(`Batch ${batch.id} ${batch.status}`);
// Read the error file before `finally` deletes it: a settled-but-failed
// batch carries its reason only there, and a bare "failed" leaves
// nothing to debug with once the remote file is gone.
const detail = await failureDetail(client, batch.error_file_id);
throw new Error(
`Batch ${batch.id} ${batch.status}${detail ? `: ${detail}` : ''}`,
);
}
const [output] = await readLines(client, batch.output_file_id);
if (output?.response?.status_code === 200) {
return output.response.body as OpenAI.Chat.ChatCompletion;
}
const [failure] = await readLines(client, batch.error_file_id);
const detail =
failure?.error?.message ??
(await failureDetail(client, batch.error_file_id)) ??
output?.error?.message ??
(output?.response?.body as { error?: { message?: string } } | undefined)
?.error?.message ??