mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
12 Commits
codex/8606
...
v2026.5.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c7e67b0f8 | ||
|
|
fcdbaaff2b | ||
|
|
638dea598c | ||
|
|
56d192be06 | ||
|
|
31d78ca77c | ||
|
|
9877a614f8 | ||
|
|
e8920158f0 | ||
|
|
85a0f1c018 | ||
|
|
8541f69f89 | ||
|
|
a14e0aefe7 | ||
|
|
3f04632448 | ||
|
|
e0d48a8913 |
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
ec8b8421bc9c316712350ffe4ea0ab284768142190fcb90cb942ca4c326f2395
|
||||
0583417c5f097db0ee54c66b98db660d3c7d17f0355d81b2aa800870c4428740
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"');
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user