mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-10-23 07:19:52 +00:00
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:
parent
439d0ce398
commit
004b7b9fe8
7 changed files with 490 additions and 17 deletions
|
|
@ -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 | 结论 |
|
||||
|
|
|
|||
239
docs/verification/batch-api/fake-dashscope.mjs
Normal file
239
docs/verification/batch-api/fake-dashscope.mjs
Normal 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`);
|
||||
});
|
||||
91
docs/verification/batch-api/regression.sh
Normal file
91
docs/verification/batch-api/regression.sh
Normal 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))
|
||||
|
|
@ -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"}'];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 ??
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue