























@@ -1,8 +1,10 @@
1+import { createServer } from "node:http";
12import type { Model } from "@mariozechner/pi-ai";
23import { describe, expect, it } from "vitest";
34import {
45buildOpenAIResponsesParams,
56buildOpenAICompletionsParams,
7+createOpenAICompletionsTransportStreamFn,
68parseTransportChunkUsage,
79resolveAzureOpenAIApiVersion,
810sanitizeTransportPayloadText,
@@ -345,6 +347,193 @@ describe("openai transport stream", () => {
345347);
346348});
347349350+it("passes provider request timeouts to OpenAI SDK clients", () => {
351+const context = { systemPrompt: "system", messages: [], tools: [] } as never;
352+const requestTimeoutMs = 900_000;
353+354+const responsesModel = {
355+id: "gpt-5.4",
356+name: "GPT-5.4",
357+api: "openai-responses",
358+provider: "custom-openai",
359+baseUrl: "https://api.example.com/v1",
360+reasoning: true,
361+input: ["text"],
362+cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
363+contextWindow: 200000,
364+maxTokens: 8192,
365+ requestTimeoutMs,
366+} satisfies Model<"openai-responses"> & { requestTimeoutMs: number };
367+const azureModel = {
368+ ...responsesModel,
369+api: "azure-openai-responses",
370+provider: "azure-openai",
371+baseUrl: "https://example.openai.azure.com/openai/deployments/gpt-5.4",
372+} satisfies Model<"azure-openai-responses"> & { requestTimeoutMs: number };
373+const completionsModel = {
374+ ...responsesModel,
375+api: "openai-completions",
376+reasoning: false,
377+} satisfies Model<"openai-completions"> & { requestTimeoutMs: number };
378+379+expect(
380+(
381+__testing.createOpenAIResponsesClient(responsesModel, context, "test-key") as {
382+timeout: number;
383+}
384+).timeout,
385+).toBe(requestTimeoutMs);
386+expect(
387+(__testing.createAzureOpenAIClient(azureModel, context, "test-key") as { timeout: number })
388+.timeout,
389+).toBe(requestTimeoutMs);
390+expect(
391+(
392+__testing.createOpenAICompletionsClient(completionsModel, context, "test-key") as {
393+timeout: number;
394+}
395+).timeout,
396+).toBe(requestTimeoutMs);
397+});
398+399+it("passes provider request timeouts to OpenAI SDK per-request options", () => {
400+const signal = new AbortController().signal;
401+const model = {
402+id: "glm-5",
403+name: "GLM-5",
404+api: "openai-completions",
405+provider: "vllm",
406+baseUrl: "http://localhost:8000/v1",
407+reasoning: false,
408+input: ["text"],
409+cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
410+contextWindow: 128000,
411+maxTokens: 4096,
412+requestTimeoutMs: 900_000.7,
413+} satisfies Model<"openai-completions"> & { requestTimeoutMs: number };
414+415+expect(__testing.buildOpenAISdkRequestOptions(model, signal)).toEqual({
416+ signal,
417+timeout: 900_000,
418+});
419+expect(
420+__testing.buildOpenAISdkRequestOptions(
421+{ ...model, requestTimeoutMs: -1 } as Model<"openai-completions">,
422+undefined,
423+),
424+).toBeUndefined();
425+});
426+427+it("streams OpenAI-compatible loopback requests with the configured SDK timeout", async () => {
428+let captured: { path?: string; timeout?: string; roles?: string[] } = {};
429+const server = createServer((req, res) => {
430+let body = "";
431+req.setEncoding("utf8");
432+req.on("data", (chunk) => {
433+body += chunk;
434+});
435+req.on("end", () => {
436+const parsed = JSON.parse(body) as { messages?: Array<{ role?: string }> };
437+captured = {
438+path: req.url,
439+timeout: Array.isArray(req.headers["x-stainless-timeout"])
440+ ? req.headers["x-stainless-timeout"][0]
441+ : req.headers["x-stainless-timeout"],
442+roles: parsed.messages?.map((message) => message.role ?? ""),
443+};
444+res.writeHead(200, {
445+"content-type": "text/event-stream; charset=utf-8",
446+"cache-control": "no-cache",
447+connection: "keep-alive",
448+});
449+const created = Math.floor(Date.now() / 1000);
450+res.write(
451+`data: ${JSON.stringify({
452+ id: "chatcmpl-timeout-proof",
453+ object: "chat.completion.chunk",
454+ created,
455+ model: "slow-local",
456+ choices: [
457+ {
458+ index: 0,
459+ delta: { role: "assistant", content: "OK" },
460+ finish_reason: null,
461+ },
462+ ],
463+ })}\n\n`,
464+);
465+res.write(
466+`data: ${JSON.stringify({
467+ id: "chatcmpl-timeout-proof",
468+ object: "chat.completion.chunk",
469+ created,
470+ model: "slow-local",
471+ choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
472+ usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
473+ })}\n\n`,
474+);
475+res.write("data: [DONE]\n\n");
476+res.end();
477+});
478+});
479+480+await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
481+try {
482+const address = server.address();
483+if (!address || typeof address === "string") {
484+throw new Error("Missing loopback server address");
485+}
486+const baseModel = {
487+id: "slow-local",
488+name: "Slow Local",
489+api: "openai-completions",
490+provider: "custom-openai-compatible",
491+baseUrl: `http://127.0.0.1:${address.port}/v1`,
492+reasoning: false,
493+input: ["text"],
494+cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
495+contextWindow: 4096,
496+maxTokens: 256,
497+requestTimeoutMs: 900_000,
498+} satisfies Model<"openai-completions"> & { requestTimeoutMs: number };
499+const model = attachModelProviderRequestTransport(baseModel, { allowPrivateNetwork: true });
500+const stream = createOpenAICompletionsTransportStreamFn()(
501+model,
502+{
503+systemPrompt: "system",
504+messages: [{ role: "user", content: "Reply OK", timestamp: Date.now() }],
505+tools: [],
506+} as never,
507+{ apiKey: "test-key" } as never,
508+);
509+510+let doneReason: string | undefined;
511+let text = "";
512+for await (const event of stream as AsyncIterable<{
513+type: string;
514+delta?: string;
515+reason?: string;
516+}>) {
517+if (event.type === "text_delta") {
518+text += event.delta ?? "";
519+}
520+if (event.type === "done") {
521+doneReason = event.reason;
522+}
523+}
524+525+expect(captured.path).toBe("/v1/chat/completions");
526+expect(captured.timeout).toBe("900");
527+expect(captured.roles).toEqual(["system", "user"]);
528+expect(doneReason).toBe("stop");
529+expect(text).toBe("OK");
530+} finally {
531+await new Promise<void>((resolve, reject) => {
532+server.close((error) => (error ? reject(error) : resolve()));
533+});
534+}
535+});
536+348537it("does not double-count reasoning tokens and clamps uncached prompt usage at zero", () => {
349538const model = {
350539id: "gpt-5",
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。