Compare commits

...

12 Commits

Author SHA1 Message Date
Peter Steinberger
9c7e67b0f8 ci: skip OpenAI install tool smoke 2026-05-10 13:59:23 +01:00
Peter Steinberger
fcdbaaff2b ci: trim OpenAI install package smoke 2026-05-10 13:42:56 +01:00
Peter Steinberger
638dea598c ci: use faster OpenAI model for installer proof 2026-05-10 13:16:12 +01:00
Peter Steinberger
56d192be06 ci: parallelize OpenAI installer proof turns 2026-05-10 12:44:24 +01:00
Peter Steinberger
31d78ca77c ci: give OpenAI package lane cleanup margin 2026-05-10 11:55:40 +01:00
Peter Steinberger
9877a614f8 test: refresh canvas bundle hash 2026-05-10 11:18:13 +01:00
Peter Steinberger
e8920158f0 fix(agents): preserve OpenAI event streams 2026-05-10 11:07:40 +01:00
Peter Steinberger
85a0f1c018 test(agents): type stream setup timeout mock 2026-05-10 10:15:50 +01:00
Peter Steinberger
8541f69f89 fix(agents): cap provider setup timeout 2026-05-10 10:03:06 +01:00
Peter Steinberger
a14e0aefe7 fix(agents): abort timed out stream setup 2026-05-10 09:54:20 +01:00
Peter Steinberger
3f04632448 fix(agents): enforce idle timeout during stream setup 2026-05-10 09:39:23 +01:00
Peter Steinberger
e0d48a8913 ci: use current OpenAI model for release smokes 2026-05-10 09:01:29 +01:00
16 changed files with 294 additions and 83 deletions

View File

@@ -618,7 +618,7 @@ jobs:
timeout_minutes: 120
- chunk_id: package-update-openai
label: package/update OpenAI install
timeout_minutes: 30
timeout_minutes: 35
- chunk_id: package-update-anthropic
label: package/update Anthropic install
timeout_minutes: 180

View File

@@ -19,6 +19,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: apply the LLM idle watchdog while provider stream setup is still pending, preventing silent pre-stream model hangs from waiting for the full agent timeout.
- Cron: let isolated self-cleanup runs inspect their own job run history while keeping other cron jobs and mutation actions blocked. Fixes #80019. Thanks @hclsys.
- CLI/config: persist explicit `config set` and `config patch` values that equal runtime defaults instead of reporting success while dropping them. Fixes #79856. (#80106) Thanks @abodanty and @hclsys.
- OpenAI/realtime voice: accept Codex-compatible legacy audio and transcript event aliases so provider protocol drift does not drop assistant audio or captions.

View File

@@ -1,2 +1,2 @@
32f0b7801c9e5e0b7ec8d7da11cec62713e968abf056560ad6372aac877fdf14 plugin-sdk-api-baseline.json
e26cfb7da5e6e8addd0bd4669bd53a4188c53f8371cb20216d854f7dd0154b1b plugin-sdk-api-baseline.jsonl
70c2f91c978fc5ef8277858c547e7a0107bca718cbae846c83ea8f9bfbfa3878 plugin-sdk-api-baseline.json
64e8df99e526a972ab5a361aea97a4b9f6e09076f47288dc3b71e9d8d978e370 plugin-sdk-api-baseline.jsonl

View File

@@ -1 +1 @@
ec8b8421bc9c316712350ffe4ea0ab284768142190fcb90cb942ca4c326f2395
0583417c5f097db0ee54c66b98db660d3c7d17f0355d81b2aa800870c4428740

View File

@@ -25,6 +25,7 @@ ANTHROPIC_API_KEY="${ANTHROPIC_API_KEY:-}"
ANTHROPIC_API_TOKEN="${ANTHROPIC_API_TOKEN:-}"
AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS:-600}"
AGENT_TURNS_PARALLEL="${OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL:-1}"
AGENT_TOOL_SMOKE="${OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE:-1}"
OPENAI_AGENT_MODEL="${OPENCLAW_INSTALL_E2E_OPENAI_MODEL:-openai/gpt-5.5}"
OPENAI_PROVIDER_TIMEOUT_SECONDS="${OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS:-${AGENT_TURN_TIMEOUT_SECONDS}}"
@@ -652,7 +653,6 @@ run_profile() {
trap cleanup_profile EXIT
phase_mark_passed "Start gateway ($profile)"
TURN1_JSON="/tmp/agent-${profile}-1.json"
TURN2_JSON="/tmp/agent-${profile}-2.json"
TURN2B_JSON="/tmp/agent-${profile}-2b.json"
TURN3_JSON="/tmp/agent-${profile}-3.json"
@@ -674,11 +674,15 @@ run_profile() {
fi
phase_mark_passed "Wait for health ($profile)"
if [[ "$AGENT_TOOL_SMOKE" == "0" ]]; then
echo "Skip agent tool smoke ($profile, OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE=0)"
cleanup_profile
trap - EXIT
return 0
fi
phase_mark_start "Agent turns ($profile)"
TURN1_SESSION_ID="${SESSION_ID_PREFIX}-read-proof"
local prompt1
prompt1="Use the read tool (not exec) to read ${PROOF_TXT}. Reply with the exact contents only (no extra whitespace)."
local prompt2
prompt2=$'Use the write tool (not exec) to write exactly this string into '"${PROOF_COPY}"$':\n'"${PROOF_VALUE}"$'\nReply with exactly: WROTE'
local prompt3
@@ -691,10 +695,11 @@ run_profile() {
TURN3_SESSION_ID="${SESSION_ID_PREFIX}-exec-hostname"
TURN3B_SESSION_ID="${SESSION_ID_PREFIX}-write-hostname"
TURN4_SESSION_ID="${SESSION_ID_PREFIX}-image-write"
# The read tool is verified below by reading the generated copy. Keep the
# initial parallel batch focused so slow hosted providers do not burn one
# redundant agent turn during release package acceptance.
if [[ "$AGENT_TURNS_PARALLEL" == "1" ]]; then
local turn_pids=()
run_agent_turn_bg "read proof" "$profile" "$TURN1_SESSION_ID" "$prompt1" "$TURN1_JSON"
turn_pids+=("$RUN_AGENT_TURN_BG_PID")
run_agent_turn_bg "write proof copy" "$profile" "$TURN2_SESSION_ID" "$prompt2" "$TURN2_JSON"
turn_pids+=("$RUN_AGENT_TURN_BG_PID")
run_agent_turn_bg "exec hostname" "$profile" "$TURN3_SESSION_ID" "$prompt3" "$TURN3_JSON"
@@ -705,22 +710,12 @@ run_profile() {
turn_pids+=("$RUN_AGENT_TURN_BG_PID")
wait_agent_turn_batch "${turn_pids[@]}"
else
run_agent_turn_logged "read proof" "$profile" "$TURN1_SESSION_ID" "$prompt1" "$TURN1_JSON"
run_agent_turn_logged "write proof copy" "$profile" "$TURN2_SESSION_ID" "$prompt2" "$TURN2_JSON"
run_agent_turn_logged "exec hostname" "$profile" "$TURN3_SESSION_ID" "$prompt3" "$TURN3_JSON"
run_agent_turn_logged "write hostname" "$profile" "$TURN3B_SESSION_ID" "$prompt3b" "$TURN3B_JSON"
run_agent_turn_logged "image write" "$profile" "$TURN4_SESSION_ID" "$prompt4" "$TURN4_JSON"
fi
assert_agent_json_has_text "$TURN1_JSON"
assert_agent_json_ok "$TURN1_JSON" "$agent_model_provider"
local reply1
reply1="$(extract_matching_text "$TURN1_JSON" "$PROOF_VALUE" | tr -d '\r\n')"
if [[ "$reply1" != "$PROOF_VALUE" ]]; then
echo "ERROR: agent did not read proof.txt correctly ($profile): $reply1" >&2
exit 1
fi
assert_agent_json_has_text "$TURN2_JSON"
assert_agent_json_ok "$TURN2_JSON" "$agent_model_provider"
local copy_value
@@ -774,7 +769,6 @@ run_profile() {
phase_mark_start "Verify tool usage via session transcript ($profile)"
# Give the gateway a moment to flush transcripts.
sleep 1
assert_session_used_tools "$(session_jsonl_path "$profile" "$TURN1_SESSION_ID")" read
assert_session_used_tools "$(session_jsonl_path "$profile" "$TURN2_SESSION_ID")" write
assert_session_used_tools "$(session_jsonl_path "$profile" "$TURN2B_SESSION_ID")" read
assert_session_used_tools "$(session_jsonl_path "$profile" "$TURN3_SESSION_ID")" exec

View File

@@ -4,7 +4,7 @@ import path from "node:path";
const command = process.argv[2];
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const agentTurnTimeoutSeconds = Number.parseInt(
process.env.OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS ?? "900",
process.env.OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS ?? "300",
10,
);

View File

@@ -11,7 +11,7 @@ IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-live-plugin-tool-e2e" OPENCLAW_
DOCKER_TARGET="${OPENCLAW_LIVE_PLUGIN_TOOL_DOCKER_TARGET:-bare}"
HOST_BUILD="${OPENCLAW_LIVE_PLUGIN_TOOL_HOST_BUILD:-1}"
PACKAGE_TGZ="${OPENCLAW_CURRENT_PACKAGE_TGZ:-}"
AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS:-900}"
AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS:-300}"
PROFILE_FILE="${OPENCLAW_LIVE_PLUGIN_TOOL_PROFILE_FILE:-${OPENCLAW_TESTBOX_PROFILE_FILE:-$HOME/.openclaw-testbox-live.profile}}"
if [ ! -f "$PROFILE_FILE" ] && [ -f "$HOME/.profile" ]; then
PROFILE_FILE="$HOME/.profile"
@@ -54,7 +54,7 @@ if ! docker_e2e_run_with_harness \
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
-e OPENAI_API_KEY \
-e OPENAI_BASE_URL \
-e OPENCLAW_LIVE_PLUGIN_TOOL_MODEL="${OPENCLAW_LIVE_PLUGIN_TOOL_MODEL:-openai/gpt-5.4-mini}" \
-e OPENCLAW_LIVE_PLUGIN_TOOL_MODEL="${OPENCLAW_LIVE_PLUGIN_TOOL_MODEL:-openai/gpt-5.5}" \
-e "OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS=$AGENT_TURN_TIMEOUT_SECONDS" \
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
@@ -140,7 +140,7 @@ openclaw agent --local \
--model "$MODEL_REF" \
--message "Call the tool named ${TOOL_NAME}. Reply with only the exact text returned by that tool. Do not compute, transform, or explain it." \
--thinking off \
--timeout "${OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS:-900}" \
--timeout "${OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS:-300}" \
--json >/tmp/openclaw-agent.json 2>/tmp/openclaw-agent.err
node scripts/e2e/lib/live-plugin-tool/assertions.mjs assert-agent-turn

View File

@@ -12,21 +12,31 @@ export async function prepareMinGitZip(tgzDir: string): Promise<string> {
String.raw`import json
import urllib.request
req = urllib.request.Request(
"https://api.github.com/repos/git-for-windows/git/releases/latest",
headers={
"User-Agent": "openclaw-parallels-smoke",
"Accept": "application/vnd.github+json",
},
)
with urllib.request.urlopen(req, timeout=30) as response:
data = json.load(response)
assets = data.get("assets", [])
preferred_names = [
"MinGit-2.53.0.2-arm64.zip",
"MinGit-2.53.0.2-64-bit.zip",
]
fallback_urls = {
"MinGit-2.53.0.2-arm64.zip": "https://github.com/git-for-windows/git/releases/download/v2.53.0.windows.2/MinGit-2.53.0.2-arm64.zip",
"MinGit-2.53.0.2-64-bit.zip": "https://github.com/git-for-windows/git/releases/download/v2.53.0.windows.2/MinGit-2.53.0.2-64-bit.zip",
}
try:
req = urllib.request.Request(
"https://api.github.com/repos/git-for-windows/git/releases/latest",
headers={
"User-Agent": "openclaw-parallels-smoke",
"Accept": "application/vnd.github+json",
},
)
with urllib.request.urlopen(req, timeout=30) as response:
data = json.load(response)
except Exception:
print(preferred_names[0])
print(fallback_urls[preferred_names[0]])
raise SystemExit(0)
assets = data.get("assets", [])
best = None
for wanted in preferred_names:

View File

@@ -128,7 +128,7 @@ const bundledPluginInstallUninstallLanes = Array.from(
function livePluginToolLane() {
return liveLane(
"live-plugin-tool",
"OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool",
"OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS=300 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool",
{
cacheKey: "plugin-tool",
e2eImageKind: "bare",
@@ -532,15 +532,17 @@ const releasePathBundledChannelLanes = [
const releasePathPackageInstallOpenAiLanes = [
npmLane(
"install-e2e-openai",
"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS=1500 OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=300 pnpm test:install:e2e",
"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=300 pnpm test:install:e2e",
{
resources: ["service"],
timeoutMs: 30 * 60 * 1000,
weight: 3,
},
),
npmLane("codex-on-demand", "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:codex-on-demand", {
resources: ["service"],
stateScenario: "empty",
timeoutMs: 30 * 60 * 1000,
weight: 3,
}),
];

View File

@@ -28,6 +28,7 @@ docker run --rm \
-e OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS="${OPENCLAW_INSTALL_E2E_SKIP_PREVIOUS:-0}" \
-e OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS="${OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS:-600}" \
-e OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL="${OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL:-1}" \
-e OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE="${OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE:-1}" \
-e OPENCLAW_NO_ONBOARD=1 \
-e OPENAI_API_KEY \
-e ANTHROPIC_API_KEY \

View File

@@ -1,3 +1,4 @@
import type { AssistantMessageEventStream } from "@mariozechner/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../../config/config.js";
import {
@@ -42,12 +43,20 @@ describe("resolveLlmIdleTimeoutMs", () => {
expect(resolveLlmIdleTimeoutMs({ runTimeoutMs: 2_147_000_000 })).toBe(0);
});
it("uses the provider request timeout as the model idle watchdog", () => {
expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 300_000 })).toBe(300_000);
it("caps remote provider request timeouts at the default idle watchdog", () => {
expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 300_000 })).toBe(
DEFAULT_LLM_IDLE_TIMEOUT_MS,
);
});
it("uses remote provider request timeouts when shorter than the default idle watchdog", () => {
expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 30_000 })).toBe(30_000);
});
it("caps provider request timeout at the max safe timeout", () => {
expect(resolveLlmIdleTimeoutMs({ modelRequestTimeoutMs: 10_000_000_000 })).toBe(2_147_000_000);
expect(
resolveLlmIdleTimeoutMs({ trigger: "cron", modelRequestTimeoutMs: 10_000_000_000 }),
).toBe(2_147_000_000);
});
it("ignores invalid provider request timeout values", () => {
@@ -248,13 +257,35 @@ describe("streamWithIdleTimeout", () => {
const baseFn = vi.fn().mockReturnValue(mockStream);
const wrapped = streamWithIdleTimeout(baseFn, 1000);
const model = { api: "openai" } as Parameters<typeof baseFn>[0];
const model = { api: "openai", requestTimeoutMs: 5000 } as Parameters<typeof baseFn>[0];
const context = {} as Parameters<typeof baseFn>[1];
const options = {} as Parameters<typeof baseFn>[2];
void wrapped(model, context, options);
expect(baseFn).toHaveBeenCalledWith(model, context, options);
expect(baseFn).toHaveBeenCalledWith(
expect.objectContaining({ api: "openai", requestTimeoutMs: 1000 }),
context,
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("keeps model request timeouts that are shorter than the idle watchdog", () => {
const mockStream = createMockAsyncIterable([]);
const baseFn = vi.fn().mockReturnValue(mockStream);
const wrapped = streamWithIdleTimeout(baseFn, 1000);
const model = { requestTimeoutMs: 250 } as Parameters<typeof baseFn>[0];
const context = {} as Parameters<typeof baseFn>[1];
const options = {} as Parameters<typeof baseFn>[2];
void wrapped(model, context, options);
expect(baseFn).toHaveBeenCalledWith(
expect.objectContaining({ requestTimeoutMs: 250 }),
context,
expect.any(Object),
);
});
it("throws on idle timeout", async () => {
@@ -275,6 +306,32 @@ describe("streamWithIdleTimeout", () => {
await next;
});
it("throws when a promise stream never resolves", async () => {
vi.useFakeTimers();
let streamSignal: AbortSignal | undefined;
const baseFn = vi.fn((_model, _context, options) => {
streamSignal = options?.signal;
return new Promise<AssistantMessageEventStream>((_resolve, reject) => {
streamSignal?.addEventListener("abort", () => {
reject(streamSignal?.reason);
});
});
});
const onIdleTimeout = vi.fn();
const wrapped = streamWithIdleTimeout(baseFn, 50, onIdleTimeout);
const model = {} as Parameters<typeof baseFn>[0];
const context = {} as Parameters<typeof baseFn>[1];
const options = {} as Parameters<typeof baseFn>[2];
const stream = expect(wrapped(model, context, options)).rejects.toThrow(/LLM idle timeout/);
await vi.advanceTimersByTimeAsync(50);
await stream;
expect(onIdleTimeout).toHaveBeenCalledTimes(1);
expect(streamSignal?.aborted).toBe(true);
});
it("resets timer on each chunk", async () => {
const chunks = [{ text: "a" }, { text: "b" }, { text: "c" }];
const mockStream = createMockAsyncIterable(chunks);

View File

@@ -127,6 +127,9 @@ export function resolveLlmIdleTimeoutMs(params?: {
value > 0 &&
value < MAX_SAFE_TIMEOUT_MS,
);
const baseUrl = params?.model?.baseUrl;
const isLocalProvider =
typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl);
const modelRequestTimeoutMs = params?.modelRequestTimeoutMs;
if (
@@ -134,7 +137,11 @@ export function resolveLlmIdleTimeoutMs(params?: {
Number.isFinite(modelRequestTimeoutMs) &&
modelRequestTimeoutMs > 0
) {
return clampTimeoutMs(Math.min(modelRequestTimeoutMs, ...timeoutBounds));
const boundedTimeoutMs = Math.min(modelRequestTimeoutMs, ...timeoutBounds);
if (params?.trigger === "cron" || isLocalProvider) {
return clampTimeoutMs(boundedTimeoutMs);
}
return clampImplicitTimeoutMs(boundedTimeoutMs);
}
if (typeof runTimeoutMs === "number" && Number.isFinite(runTimeoutMs) && runTimeoutMs > 0) {
@@ -157,8 +164,7 @@ export function resolveLlmIdleTimeoutMs(params?: {
// prompt evaluation or thinking, so falling back to the default would abort
// valid local runs. Honor it only when the user has not opted out via the
// baseUrl pointing at loopback / private-network / `.local`.
const baseUrl = params?.model?.baseUrl;
if (typeof baseUrl === "string" && baseUrl.length > 0 && isLocalProviderBaseUrl(baseUrl)) {
if (isLocalProvider) {
return 0;
}
@@ -180,7 +186,63 @@ export function streamWithIdleTimeout(
onIdleTimeout?: (error: Error) => void,
): StreamFn {
return (model, context, options) => {
const maybeStream = baseFn(model, context, options);
const createIdleTimeoutError = () =>
new Error(`LLM idle timeout (${Math.floor(timeoutMs / 1000)}s): no response from model`);
const streamAbortController = new AbortController();
const sourceSignal = options?.signal;
const abortStream = (reason?: unknown) => {
if (!streamAbortController.signal.aborted) {
streamAbortController.abort(reason);
}
};
const abortFromSourceSignal = () => abortStream(sourceSignal?.reason);
if (sourceSignal?.aborted) {
abortFromSourceSignal();
} else {
sourceSignal?.addEventListener("abort", abortFromSourceSignal, { once: true });
}
const cleanupSourceSignal = () => {
sourceSignal?.removeEventListener("abort", abortFromSourceSignal);
};
const wrappedOptions = {
...options,
signal: streamAbortController.signal,
} as typeof options;
const existingRequestTimeoutMs =
typeof (model as { requestTimeoutMs?: unknown })?.requestTimeoutMs === "number" &&
Number.isFinite((model as { requestTimeoutMs?: number }).requestTimeoutMs) &&
(model as { requestTimeoutMs?: number }).requestTimeoutMs! > 0
? Math.floor((model as { requestTimeoutMs?: number }).requestTimeoutMs!)
: timeoutMs;
const wrappedModel =
typeof model === "object" && model !== null
? ({
...model,
requestTimeoutMs: Math.min(existingRequestTimeoutMs, timeoutMs),
} as typeof model)
: model;
const createTimeoutPromise = (setTimer: (timer: NodeJS.Timeout) => void): Promise<never> => {
return new Promise((_, reject) => {
const timer = setTimeout(() => {
const error = createIdleTimeoutError();
abortStream(error);
onIdleTimeout?.(error);
reject(error);
}, timeoutMs);
timer.unref?.();
setTimer(timer);
});
};
let maybeStream: ReturnType<StreamFn>;
try {
maybeStream = baseFn(wrappedModel, context, wrappedOptions);
} catch (error) {
cleanupSourceSignal();
throw error;
}
const wrapStream = (stream: ReturnType<typeof streamSimple>) => {
const originalAsyncIterator = stream[Symbol.asyncIterator].bind(stream);
@@ -189,18 +251,6 @@ export function streamWithIdleTimeout(
const iterator = originalAsyncIterator();
let idleTimer: NodeJS.Timeout | null = null;
const createTimeoutPromise = (): Promise<never> => {
return new Promise((_, reject) => {
idleTimer = setTimeout(() => {
const error = new Error(
`LLM idle timeout (${Math.floor(timeoutMs / 1000)}s): no response from model`,
);
onIdleTimeout?.(error);
reject(error);
}, timeoutMs);
});
};
const clearTimer = () => {
if (idleTimer) {
clearTimeout(idleTimer);
@@ -215,10 +265,16 @@ export function streamWithIdleTimeout(
try {
// Race between the actual next() and the timeout
const result = await Promise.race([streamIterator.next(), createTimeoutPromise()]);
const result = await Promise.race([
streamIterator.next(),
createTimeoutPromise((timer) => {
idleTimer = timer;
}),
]);
if (result.done) {
clearTimer();
cleanupSourceSignal();
return result;
}
@@ -231,10 +287,12 @@ export function streamWithIdleTimeout(
},
onReturn(streamIterator) {
clearTimer();
cleanupSourceSignal();
return streamIterator.return?.() ?? Promise.resolve({ done: true, value: undefined });
},
onThrow(streamIterator, error) {
clearTimer();
cleanupSourceSignal();
return streamIterator.throw?.(error) ?? Promise.reject(error);
},
});
@@ -244,7 +302,30 @@ export function streamWithIdleTimeout(
};
if (maybeStream && typeof maybeStream === "object" && "then" in maybeStream) {
return Promise.resolve(maybeStream).then(wrapStream);
let streamPromiseTimer: NodeJS.Timeout | null = null;
const clearStreamPromiseTimer = () => {
if (streamPromiseTimer) {
clearTimeout(streamPromiseTimer);
streamPromiseTimer = null;
}
};
return Promise.race([
Promise.resolve(maybeStream),
createTimeoutPromise((timer) => {
streamPromiseTimer = timer;
}),
]).then(
(stream) => {
clearStreamPromiseTimer();
return wrapStream(stream);
},
(error) => {
clearStreamPromiseTimer();
cleanupSourceSignal();
throw error;
},
);
}
return wrapStream(maybeStream);
};

View File

@@ -281,12 +281,12 @@ describe("buildGuardedModelFetch", () => {
});
const model = {
id: "gpt-5.4",
provider: "openai",
provider: "openrouter",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-responses">;
const response = await buildGuardedModelFetch(model)("https://api.openai.com/v1/responses", {
const response = await buildGuardedModelFetch(model)("https://openrouter.ai/api/v1/responses", {
method: "POST",
});
const items = [];
@@ -297,6 +297,30 @@ describe("buildGuardedModelFetch", () => {
expect(items).toEqual([{ ok: true }]);
});
it("leaves official OpenAI SSE streams unmodified", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('event: response.created\n\ndata: {"ok": true}\n\n', {
headers: { "content-type": "text/event-stream" },
}),
finalUrl: "https://api.openai.com/v1/responses",
release: vi.fn(async () => undefined),
});
const model = {
id: "gpt-5.5",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
} as unknown as Model<"openai-responses">;
const response = await buildGuardedModelFetch(model)("https://api.openai.com/v1/responses", {
method: "POST",
});
await expect(response.text()).resolves.toBe(
'event: response.created\n\ndata: {"ok": true}\n\n',
);
});
it("drops whitespace-only SSE data frames with CRLF delimiters", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('event: message\r\ndata: \r\n\r\ndata: {"ok": true}\r\n\r\n', {
@@ -307,13 +331,13 @@ describe("buildGuardedModelFetch", () => {
});
const model = {
id: "gpt-5.4",
provider: "openai",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://api.openai.com/v1",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://api.openai.com/v1/chat/completions",
"https://openrouter.ai/api/v1/chat/completions",
{ method: "POST" },
);
const items = [];
@@ -356,6 +380,33 @@ describe("buildGuardedModelFetch", () => {
expect(items).toEqual([{ ok: true }]);
});
it("does not clone Request bodies while checking for streaming JSON fallbacks", async () => {
const cloneSpy = vi.spyOn(Request.prototype, "clone");
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"ok": true}', {
headers: { "content-type": "application/json" },
}),
finalUrl: "https://api.openai.com/v1/responses",
release: vi.fn(async () => undefined),
});
const model = {
id: "gpt-5.5",
provider: "openai",
api: "openai-responses",
baseUrl: "https://api.openai.com/v1",
} as unknown as Model<"openai-responses">;
const request = new Request("https://api.openai.com/v1/responses", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ model: "gpt-5.5", stream: true }),
});
const response = await buildGuardedModelFetch(model)(request);
expect(cloneSpy).not.toHaveBeenCalled();
expect(response.headers.get("content-type")).toBe("application/json");
});
it("preserves JSON bodies when the request is not streaming", async () => {
fetchWithSsrFGuardMock.mockResolvedValue({
response: new Response('{"ok": true}', {
@@ -439,13 +490,13 @@ describe("buildGuardedModelFetch", () => {
});
const model = {
id: "gpt-5.4",
provider: "openai",
provider: "openrouter",
api: "openai-completions",
baseUrl: "https://api.openai.com/v1",
baseUrl: "https://openrouter.ai/api/v1",
} as unknown as Model<"openai-completions">;
const response = await buildGuardedModelFetch(model)(
"https://api.openai.com/v1/chat/completions",
"https://openrouter.ai/api/v1/chat/completions",
{ method: "POST" },
);
const items = [];

View File

@@ -164,6 +164,17 @@ function sanitizeOpenAISdkSseResponse(
});
}
function shouldSanitizeOpenAISdkSseResponse(model: Model<Api>): boolean {
if (model.provider !== "openai") {
return true;
}
try {
return new URL(model.baseUrl).hostname.toLowerCase() !== "api.openai.com";
} catch {
return true;
}
}
async function requestBodyHasStreamTrue(
request: Request | undefined,
init: RequestInit | undefined,
@@ -179,12 +190,7 @@ async function requestBodyHasStreamTrue(
}
let text: string | undefined;
if (request) {
text = await request
.clone()
.text()
.catch(() => undefined);
} else if (typeof init?.body === "string") {
if (typeof init?.body === "string") {
text = init.body;
}
if (!text) {
@@ -460,6 +466,8 @@ export function buildGuardedModelFetch(model: Model<Api>, timeoutMs?: number): t
});
}
response = buildManagedResponse(response, result.release, result.refreshTimeout);
return sanitizeOpenAISdkSseResponse(response, { synthesizeJsonAsSse });
return shouldSanitizeOpenAISdkSseResponse(model)
? sanitizeOpenAISdkSseResponse(response, { synthesizeJsonAsSse })
: response;
};
}

View File

@@ -120,7 +120,7 @@ describe("docker build helper", () => {
const openWebUiRunner = readFileSync(OPENWEBUI_DOCKER_E2E_PATH, "utf8");
expect(scenarios).toContain(
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS=1500 OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=300 pnpm test:install:e2e"',
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=300 pnpm test:install:e2e"',
);
expect(scenarios).toContain(
'"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=anthropic OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-anthropic:local pnpm test:install:e2e"',
@@ -145,10 +145,11 @@ describe("docker build helper", () => {
expect(runner).toContain("phase_mark_start");
expect(runner).toContain("run_agent_turn_bg");
expect(runner).toContain("wait_agent_turn_batch");
expect(runner).toContain('run_agent_turn_bg "read proof"');
expect(runner).not.toContain('run_agent_turn_bg "read proof"');
expect(runner).toContain('run_agent_turn_bg "image write"');
expect(runner).toContain('run_agent_turn_logged "read proof copy"');
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL");
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE");
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_OPENAI_MODEL");
expect(wrapper).toContain("OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS");
expect(runner).toContain("OPENCLAW_INSTALL_E2E_OPENAI_MODEL");
@@ -265,7 +266,7 @@ describe("docker build helper", () => {
const runner = readFileSync(INSTALL_E2E_RUNNER_PATH, "utf8");
expect(runner).toContain('SESSION_ID_PREFIX="e2e-tools-${profile}"');
expect(runner).toContain('TURN1_SESSION_ID="${SESSION_ID_PREFIX}-read-proof"');
expect(runner).toContain('TURN2B_SESSION_ID="${SESSION_ID_PREFIX}-read-copy"');
expect(runner).toContain('TURN3_SESSION_ID="${SESSION_ID_PREFIX}-exec-hostname"');
expect(runner).toContain('TURN4_SESSION_ID="${SESSION_ID_PREFIX}-image-write"');
});

View File

@@ -330,7 +330,8 @@ describe("scripts/lib/docker-e2e-plan", () => {
weight: 2,
},
{
command: "OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool",
command:
"OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS=300 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool",
imageKind: "bare",
live: true,
name: "live-plugin-tool",
@@ -634,6 +635,7 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(lane.name).toBe("codex-on-demand");
expect(lane.resources).toEqual(["docker", "npm", "service"]);
expect(lane.stateScenario).toBe("empty");
expect(lane.timeoutMs).toBe(1_800_000);
expect(plan.needs.bareImage).toBe(true);
expect(plan.needs.package).toBe(true);
});
@@ -644,7 +646,9 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.credentials).toEqual(["openai"]);
expect(plan.lanes).toHaveLength(1);
const lane = requireFirstLane(plan);
expect(lane.command).toBe("OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool");
expect(lane.command).toBe(
"OPENCLAW_LIVE_PLUGIN_TOOL_TIMEOUT_SECONDS=300 OPENCLAW_SKIP_DOCKER_BUILD=1 pnpm test:docker:live-plugin-tool",
);
expect(lane.imageKind).toBe("bare");
expect(lane.live).toBe(true);
expect(lane.name).toBe("live-plugin-tool");
@@ -751,11 +755,12 @@ describe("scripts/lib/docker-e2e-plan", () => {
expect(plan.lanes.map(summarizeLane)).toEqual([
{
command:
"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TURN_TIMEOUT_SECONDS=1500 OPENCLAW_INSTALL_E2E_AGENT_TURNS_PARALLEL=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=300 pnpm test:install:e2e",
"OPENCLAW_INSTALL_TAG=beta OPENCLAW_E2E_MODELS=openai OPENCLAW_INSTALL_E2E_IMAGE=openclaw-install-e2e-openai:local OPENCLAW_INSTALL_E2E_AGENT_TOOL_SMOKE=0 OPENCLAW_INSTALL_E2E_OPENAI_MODEL=openai/gpt-5.4-mini OPENCLAW_INSTALL_E2E_OPENAI_PROVIDER_TIMEOUT_SECONDS=300 pnpm test:install:e2e",
imageKind: "bare",
live: false,
name: "install-e2e-openai",
resources: ["docker", "npm", "service"],
timeoutMs: 1_800_000,
weight: 3,
},
{