fix(openai): honor per-turn timeout and retry controls (#115017)

Honor existing per-turn timeout and retry controls across OpenAI Responses, Azure Responses, and OpenAI-compatible Chat Completions. Add real SDK HTTP regressions for all three transports.\n\nRefs: #114203
This commit is contained in:
Peter Steinberger
2026-07-28 05:04:51 -04:00
committed by GitHub
parent c9b0f4260f
commit d1b8701e37
5 changed files with 135 additions and 14 deletions
@@ -335,7 +335,10 @@ export function createOpenAICompletionsTransportStreamFn(): StreamFn {
firstEventAbort = createFirstStreamEventAbortController(options?.signal);
const responseStream = (await client.chat.completions.create(
params as never,
buildOpenAISdkRequestOptions(model, firstEventAbort.signal),
buildOpenAISdkRequestOptions(model, firstEventAbort.signal, {
timeoutMs: options?.timeoutMs,
maxRetries: options?.maxRetries,
}),
)) as unknown as AsyncIterable<ChatCompletionChunk>;
stream.push({ type: "start", partial: output as never });
await processOpenAICompletionsStream(responseStream, output, model, stream, {
@@ -190,11 +190,11 @@ function createResponsesTransportExecutor(config: ResponsesTransportExecutorOpti
}
const requestStartedAt = Date.now();
firstEventAbort = createFirstStreamEventAbortController(options?.signal);
const requestOptions = buildOpenAISdkRequestOptions(
model,
firstEventAbort.signal,
config.streamRequest ? { stream: true } : undefined,
);
const requestOptions = buildOpenAISdkRequestOptions(model, firstEventAbort.signal, {
stream: config.streamRequest,
timeoutMs: options?.timeoutMs,
maxRetries: options?.maxRetries,
});
emitModelTransportDebug(
log,
`[responses] start provider=${model.provider} api=${model.api} model=${model.id} ` +
@@ -1,7 +1,10 @@
import type { Context, Model } from "@openclaw/llm-core";
import { describe, expect, it } from "vitest";
import { OPENAI_PROMPT_CACHE_KEY_MAX_LENGTH } from "../providers/openai-prompt-cache.js";
import { buildOpenAIClientHeaders } from "./openai-transport-params.js";
import {
buildOpenAIClientHeaders,
buildOpenAISdkRequestOptions,
} from "./openai-transport-params.js";
const codexModel = {
id: "gpt-5.6-luna",
@@ -35,3 +38,27 @@ describe("buildOpenAIClientHeaders session_id affinity header", () => {
expect(headers.session_id).toBe("abc-123");
});
});
describe("buildOpenAISdkRequestOptions turn controls", () => {
const model = {
id: "gpt-5.6-luna",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
} as Model;
it("forwards an explicit turn timeout and zero retries to the SDK", () => {
const signal = new AbortController().signal;
expect(
buildOpenAISdkRequestOptions(model, signal, {
timeoutMs: 1_234,
maxRetries: 0,
}),
).toEqual({ signal, timeout: 1_234, maxRetries: 0 });
});
it("does not add a retry policy when the turn does not specify one", () => {
expect(buildOpenAISdkRequestOptions(model)).toBeUndefined();
});
});
@@ -225,8 +225,8 @@ export function buildOpenAIClientHeaders(
return resolvedHeaders;
}
function resolveOpenAISdkTimeoutMs(model: Model): number | undefined {
return resolveModelRequestTimeoutMs(model, undefined);
function resolveOpenAISdkTimeoutMs(model: Model, timeoutMs?: number): number | undefined {
return resolveModelRequestTimeoutMs(model, timeoutMs);
}
export function buildOpenAISdkClientOptions(model: Model): { timeout?: number } {
@@ -237,20 +237,28 @@ export function buildOpenAISdkClientOptions(model: Model): { timeout?: number }
export function buildOpenAISdkRequestOptions(
model: Model,
signal?: AbortSignal,
options?: { stream?: boolean },
): { signal?: AbortSignal; timeout?: number; headers?: Record<string, string> } | undefined {
const timeout = resolveOpenAISdkTimeoutMs(model);
options?: { stream?: boolean; timeoutMs?: number; maxRetries?: number },
):
| {
signal?: AbortSignal;
timeout?: number;
maxRetries?: number;
headers?: Record<string, string>;
}
| undefined {
const timeout = resolveOpenAISdkTimeoutMs(model, options?.timeoutMs);
const headers =
options?.stream === true && usesNativeOpenAICodexResponsesBackend(model)
? { Accept: "text/event-stream" }
: undefined;
if (timeout === undefined && !signal && !headers) {
if (timeout === undefined && options?.maxRetries === undefined && !signal && !headers) {
return undefined;
}
return {
...(headers ? { headers } : {}),
...(signal ? { signal } : {}),
...(timeout !== undefined ? { timeout } : {}),
...(options?.maxRetries !== undefined ? { maxRetries: options.maxRetries } : {}),
};
}
@@ -1,5 +1,9 @@
import { createServer } from "node:http";
import { createOpenAICompletionsTransportStreamFn } from "@openclaw/ai/transports";
import {
createAzureOpenAIResponsesTransportStreamFn,
createOpenAICompletionsTransportStreamFn,
createOpenAIResponsesTransportStreamFn,
} from "@openclaw/ai/transports";
import type { Model } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import {
@@ -82,6 +86,85 @@ describe("openai transport stream", () => {
).toBeUndefined();
});
it.each([
{
api: "openai-responses" as const,
provider: "openai",
createStream: createOpenAIResponsesTransportStreamFn,
},
{
api: "azure-openai-responses" as const,
provider: "azure-openai",
createStream: createAzureOpenAIResponsesTransportStreamFn,
},
{
api: "openai-completions" as const,
provider: "openai",
createStream: createOpenAICompletionsTransportStreamFn,
},
])("honors turn timeout and zero retries over real $api HTTP", async (transport) => {
const capturedTimeouts: Array<string | undefined> = [];
const server = createServer((request, response) => {
const timeout = request.headers["x-stainless-timeout"];
capturedTimeouts.push(Array.isArray(timeout) ? timeout[0] : timeout);
request.resume();
request.on("end", () => {
response.writeHead(500, { "content-type": "application/json" });
response.end(
JSON.stringify({
error: { type: "server_error", message: "turn retry regression" },
}),
);
});
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", resolve);
});
try {
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Missing loopback server address");
}
const model = {
id: "gpt-5.6-luna",
name: "GPT-5.6 Luna",
api: transport.api,
provider: transport.provider,
baseUrl: `http://127.0.0.1:${address.port}/v1`,
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128_000,
maxTokens: 4_096,
requestTimeoutMs: 900_000,
} satisfies Model & { requestTimeoutMs: number };
const stream = await transport.createStream()(
model,
{
messages: [{ role: "user", content: "Reply OK", timestamp: Date.now() }],
tools: [],
},
{ apiKey: "test-key", timeoutMs: 1_234, maxRetries: 0 },
);
const eventTypes: string[] = [];
for await (const event of stream) {
eventTypes.push(event.type);
}
expect(eventTypes).toContain("error");
// The SDK advertises request timeouts in whole seconds on the wire.
expect(capturedTimeouts).toEqual(["1"]);
} finally {
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
});
it("streams OpenAI-compatible loopback requests with the configured SDK timeout", async () => {
let captured: { path?: string; timeout?: string; model?: string; roles?: string[] } = {};
const server = createServer((req, res) => {