Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
22
packages/ai/src/utils/deferred-event-buffer.ts
Normal file
22
packages/ai/src/utils/deferred-event-buffer.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
type EventSink<T> = {
|
||||
push(event: T): void;
|
||||
};
|
||||
|
||||
export function createDeferredEventBuffer<T>(sink: EventSink<T>, onBufferedEvent?: () => void) {
|
||||
let events: T[] = [];
|
||||
return {
|
||||
push(event: T): void {
|
||||
events.push(event);
|
||||
onBufferedEvent?.();
|
||||
},
|
||||
flush(): void {
|
||||
for (const event of events) {
|
||||
sink.push(event);
|
||||
}
|
||||
events = [];
|
||||
},
|
||||
discard(): void {
|
||||
events = [];
|
||||
},
|
||||
};
|
||||
}
|
||||
2
packages/ai/src/utils/diagnostics.ts
Normal file
2
packages/ai/src/utils/diagnostics.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Shared provider diagnostics. */
|
||||
export * from "@openclaw/llm-core/diagnostics";
|
||||
2
packages/ai/src/utils/event-stream.ts
Normal file
2
packages/ai/src/utils/event-stream.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
/** Assistant message event stream implementation. */
|
||||
export * from "@openclaw/llm-core/event-stream";
|
||||
13
packages/ai/src/utils/hash.ts
Normal file
13
packages/ai/src/utils/hash.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/** Fast deterministic hash to shorten long strings */
|
||||
export function shortHash(str: string): string {
|
||||
let h1 = 0xdeadbeef;
|
||||
let h2 = 0x41c6ce57;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
const ch = str.charCodeAt(i);
|
||||
h1 = Math.imul(h1 ^ ch, 2654435761);
|
||||
h2 = Math.imul(h2 ^ ch, 1597334677);
|
||||
}
|
||||
h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909);
|
||||
h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909);
|
||||
return (h2 >>> 0).toString(36) + (h1 >>> 0).toString(36);
|
||||
}
|
||||
8
packages/ai/src/utils/headers.ts
Normal file
8
packages/ai/src/utils/headers.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** Converts a Headers object to a plain record for provider request handling. */
|
||||
export function headersToRecord(headers: Headers): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const [key, value] of headers.entries()) {
|
||||
result[key] = value;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
153
packages/ai/src/utils/json-parse.ts
Normal file
153
packages/ai/src/utils/json-parse.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// JSON parse helpers recover structured values from partial model output.
|
||||
import { parse as partialParse } from "partial-json";
|
||||
|
||||
const VALID_JSON_ESCAPES = new Set(['"', "\\", "/", "b", "f", "n", "r", "t", "u"]);
|
||||
const JSON_CONTROL_ESCAPES = new Set(["b", "f", "n", "r", "t"]);
|
||||
|
||||
function isControlCharacter(char: string): boolean {
|
||||
const codePoint = char.codePointAt(0);
|
||||
return codePoint !== undefined && codePoint >= 0x00 && codePoint <= 0x1f;
|
||||
}
|
||||
|
||||
function escapeControlCharacter(char: string): string {
|
||||
switch (char) {
|
||||
case "\b":
|
||||
return "\\b";
|
||||
case "\f":
|
||||
return "\\f";
|
||||
case "\n":
|
||||
return "\\n";
|
||||
case "\r":
|
||||
return "\\r";
|
||||
case "\t":
|
||||
return "\\t";
|
||||
default:
|
||||
return `\\u${char.codePointAt(0)?.toString(16).padStart(4, "0") ?? "0000"}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repairs malformed JSON string literals by:
|
||||
* - escaping raw control characters inside strings
|
||||
* - doubling backslashes before invalid escape characters
|
||||
*/
|
||||
export function repairJson(json: string): string {
|
||||
let repaired = "";
|
||||
let inString = false;
|
||||
let stringValuePrefix = "";
|
||||
|
||||
for (let index = 0; index < json.length; index++) {
|
||||
const char = json[index];
|
||||
|
||||
if (!inString) {
|
||||
repaired += char;
|
||||
if (char === '"') {
|
||||
inString = true;
|
||||
stringValuePrefix = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
repaired += char;
|
||||
inString = false;
|
||||
stringValuePrefix = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "\\") {
|
||||
const nextChar = json[index + 1];
|
||||
if (nextChar === undefined) {
|
||||
repaired += "\\\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (nextChar === "u") {
|
||||
const unicodeDigits = json.slice(index + 2, index + 6);
|
||||
if (/^[0-9a-fA-F]{4}$/.test(unicodeDigits)) {
|
||||
repaired += `\\u${unicodeDigits}`;
|
||||
stringValuePrefix += `\\u${unicodeDigits}`;
|
||||
index += 5;
|
||||
continue;
|
||||
}
|
||||
// A \u not followed by four hex digits is an invalid escape: double the
|
||||
// backslash like the other invalid escapes below. Falling through would
|
||||
// hit the valid-escape branch (VALID_JSON_ESCAPES contains "u") and
|
||||
// re-emit the broken \u, leaving the JSON unparseable.
|
||||
repaired += "\\\\";
|
||||
stringValuePrefix += "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (JSON_CONTROL_ESCAPES.has(nextChar) && looksLikeWindowsPathPrefix(stringValuePrefix)) {
|
||||
repaired += "\\\\";
|
||||
stringValuePrefix += "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (VALID_JSON_ESCAPES.has(nextChar)) {
|
||||
repaired += `\\${nextChar}`;
|
||||
stringValuePrefix += nextChar === "\\" ? "\\" : `\\${nextChar}`;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
repaired += "\\\\";
|
||||
stringValuePrefix += "\\";
|
||||
continue;
|
||||
}
|
||||
|
||||
repaired += isControlCharacter(char) ? escapeControlCharacter(char) : char;
|
||||
stringValuePrefix += char;
|
||||
}
|
||||
|
||||
return repaired;
|
||||
}
|
||||
|
||||
export function parseJsonWithRepair(json: string): unknown {
|
||||
const repairedJson = repairJson(json);
|
||||
if (repairedJson !== json) {
|
||||
return JSON.parse(repairedJson) as unknown;
|
||||
}
|
||||
return JSON.parse(json) as unknown;
|
||||
}
|
||||
|
||||
function looksLikeWindowsPathPrefix(prefix: string): boolean {
|
||||
const tail = prefix.slice(-160);
|
||||
return /(?:^|[^A-Za-z0-9])[A-Za-z]:(?:[\\/][^"\\/:*?<>|\r\n]*)*$/.test(tail);
|
||||
}
|
||||
|
||||
function asStreamingJsonRecord(value: unknown): Record<string, unknown> {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to parse potentially incomplete JSON during streaming.
|
||||
* Always returns a valid object, even if the JSON is incomplete.
|
||||
*
|
||||
* @param partialJson The partial JSON string from streaming
|
||||
* @returns Parsed object or empty object if parsing fails
|
||||
*/
|
||||
export function parseStreamingJson(partialJson: string | undefined): Record<string, unknown> {
|
||||
if (!partialJson || partialJson.trim() === "") {
|
||||
return {};
|
||||
}
|
||||
|
||||
try {
|
||||
return asStreamingJsonRecord(parseJsonWithRepair(partialJson));
|
||||
} catch {
|
||||
try {
|
||||
const result = partialParse(partialJson);
|
||||
return asStreamingJsonRecord(result);
|
||||
} catch {
|
||||
try {
|
||||
const result = partialParse(repairJson(partialJson));
|
||||
return asStreamingJsonRecord(result);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
packages/ai/src/utils/llm-request-activity.ts
Normal file
23
packages/ai/src/utils/llm-request-activity.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
const requestActivityListeners = new WeakMap<AbortSignal, Set<() => void>>();
|
||||
|
||||
export function notifyLlmRequestActivity(signal: AbortSignal | undefined): void {
|
||||
if (!signal) {
|
||||
return;
|
||||
}
|
||||
for (const listener of requestActivityListeners.get(signal) ?? []) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
export function onLlmRequestActivity(signal: AbortSignal, listener: () => void): () => void {
|
||||
const listeners = requestActivityListeners.get(signal) ?? new Set<() => void>();
|
||||
listeners.add(listener);
|
||||
requestActivityListeners.set(signal, listeners);
|
||||
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
if (listeners.size === 0) {
|
||||
requestActivityListeners.delete(signal);
|
||||
}
|
||||
};
|
||||
}
|
||||
32
packages/ai/src/utils/oauth/openai-chatgpt-jwt.ts
Normal file
32
packages/ai/src/utils/oauth/openai-chatgpt-jwt.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// OpenAI ChatGPT JWT helpers inspect auth claims for ChatGPT OAuth sessions.
|
||||
const OPENAI_CODEX_AUTH_CLAIM = "https://api.openai.com/auth";
|
||||
|
||||
export type OpenAICodexJwtPayload = {
|
||||
[OPENAI_CODEX_AUTH_CLAIM]?: {
|
||||
chatgpt_account_id?: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
export function decodeOpenAICodexJwtPayload(token: string): OpenAICodexJwtPayload | null {
|
||||
const parts = token.split(".");
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decoded = Buffer.from(parts[1] ?? "", "base64url").toString("utf8");
|
||||
const parsed = JSON.parse(decoded);
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as OpenAICodexJwtPayload)
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOpenAICodexAccountId(token: string): string | null {
|
||||
const accountId =
|
||||
decodeOpenAICodexJwtPayload(token)?.[OPENAI_CODEX_AUTH_CLAIM]?.chatgpt_account_id;
|
||||
return typeof accountId === "string" && accountId.length > 0 ? accountId : null;
|
||||
}
|
||||
72
packages/ai/src/utils/overflow.test.ts
Normal file
72
packages/ai/src/utils/overflow.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AssistantMessage } from "../types.js";
|
||||
import { isConfiguredContextSizeOverflowError, isContextOverflow } from "./overflow.js";
|
||||
|
||||
function errorMessage(message: string): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "test-api",
|
||||
provider: "test-provider",
|
||||
model: "test-model",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "error",
|
||||
errorMessage: message,
|
||||
timestamp: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function successfulMessage(
|
||||
contextUsage?: AssistantMessage["usage"]["contextUsage"],
|
||||
): AssistantMessage {
|
||||
return {
|
||||
...errorMessage(""),
|
||||
usage: {
|
||||
input: 12,
|
||||
output: 15_104,
|
||||
cacheRead: 1_100_000,
|
||||
cacheWrite: 93_130,
|
||||
...(contextUsage ? { contextUsage } : {}),
|
||||
totalTokens: 1_208_246,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
errorMessage: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
describe("configured context size overflow", () => {
|
||||
it.each([
|
||||
"400 Prompt has 256468 tokens, but the configured context size is 256000 tokens",
|
||||
"Prompt has 5,958,968 tokens, but the configured context size is 256,000 tokens",
|
||||
])("detects %s", (text) => {
|
||||
expect(isConfiguredContextSizeOverflowError(text)).toBe(true);
|
||||
expect(isContextOverflow(errorMessage(text), 256_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usage-based overflow", () => {
|
||||
it("prefers an available context snapshot over aggregate billing usage", () => {
|
||||
expect(
|
||||
isContextOverflow(
|
||||
successfulMessage({
|
||||
state: "available",
|
||||
promptTokens: 148_874,
|
||||
totalTokens: 163_978,
|
||||
}),
|
||||
1_000_000,
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not infer overflow from aggregate billing when context is unavailable", () => {
|
||||
expect(isContextOverflow(successfulMessage({ state: "unavailable" }), 1_000_000)).toBe(false);
|
||||
});
|
||||
});
|
||||
171
packages/ai/src/utils/overflow.ts
Normal file
171
packages/ai/src/utils/overflow.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
// Overflow helpers classify provider overflow errors and retryable responses.
|
||||
import type { AssistantMessage } from "../types.js";
|
||||
|
||||
const CONFIGURED_CONTEXT_SIZE_OVERFLOW_RE =
|
||||
/prompt has [\d,]+ tokens?, but the configured context size is [\d,]+ tokens?/i;
|
||||
|
||||
/** Detects DS4-style raw token-count context overflow errors. */
|
||||
export function isConfiguredContextSizeOverflowError(errorMessage: string): boolean {
|
||||
return CONFIGURED_CONTEXT_SIZE_OVERFLOW_RE.test(errorMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Regex patterns to detect context overflow errors from different providers.
|
||||
*
|
||||
* These patterns match error messages returned when the input exceeds
|
||||
* the model's context window.
|
||||
*
|
||||
* Provider-specific patterns (with example error messages):
|
||||
*
|
||||
* - Anthropic: "prompt is too long: 213462 tokens > 200000 maximum"
|
||||
* - Anthropic: "413 {\"error\":{\"type\":\"request_too_large\",\"message\":\"Request exceeds the maximum size\"}}"
|
||||
* - OpenAI: "Your input exceeds the context window of this model"
|
||||
* - OpenAI/LiteLLM: "Requested token count exceeds the model's maximum context length of 131072 tokens"
|
||||
* - Google: "The input token count (1196265) exceeds the maximum number of tokens allowed (1048575)"
|
||||
* - xAI: "This model's maximum prompt length is 131072 but the request contains 537812 tokens"
|
||||
* - Groq: "Please reduce the length of the messages or completion"
|
||||
* - OpenRouter: "This endpoint's maximum context length is X tokens. However, you requested about Y tokens"
|
||||
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
|
||||
* - llama.cpp: "the request exceeds the available context size, try increasing it"
|
||||
* - LM Studio: "tokens to keep from the initial prompt is greater than the context length"
|
||||
* - GitHub Copilot: "prompt token count of X exceeds the limit of Y"
|
||||
* - MiniMax: "invalid params, context window exceeds limit"
|
||||
* - Kimi For Coding: "Your request exceeded model token limit: X (requested: Y)"
|
||||
* - Cerebras: "400/413 status code (no body)"
|
||||
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
|
||||
* - z.ai: Does NOT error, accepts overflow silently - handled via usage.input > contextWindow
|
||||
* - Xiaomi MiMo: Truncates input to fill contextWindow exactly, then returns finish_reason "length"
|
||||
* with output=0 (no room left to generate). Detected via stopReason "length" + zero output +
|
||||
* input filling the context window.
|
||||
* - Ollama: Some deployments truncate silently, others return errors like "prompt too long; exceeded max context length by X tokens"
|
||||
*/
|
||||
const OVERFLOW_PATTERNS = [
|
||||
/prompt is too long/i, // Anthropic token overflow
|
||||
/request_too_large/i, // Anthropic request byte-size overflow (HTTP 413)
|
||||
/input is too long for requested model/i, // Amazon Bedrock
|
||||
/exceeds the context window/i, // OpenAI (Completions & Responses API)
|
||||
/exceeds (?:the )?(?:model'?s )?maximum context length of [\d,]+ tokens?/i, // OpenAI-compatible proxies (LiteLLM)
|
||||
/input token count.*exceeds the maximum/i, // Google (Gemini)
|
||||
/maximum prompt length is \d+/i, // xAI (Grok)
|
||||
/reduce the length of the messages/i, // Groq
|
||||
/maximum context length is \d+ tokens/i, // OpenRouter (all backends)
|
||||
/input \(\d+ tokens\) is longer than the model'?s context length \(\d+ tokens\)/i, // Together AI
|
||||
/exceeds the limit of \d+/i, // GitHub Copilot
|
||||
/exceeds the available context size/i, // llama.cpp server
|
||||
/greater than the context length/i, // LM Studio
|
||||
/context window exceeds limit/i, // MiniMax
|
||||
/exceeded model token limit/i, // Kimi For Coding
|
||||
/too large for model with \d+ maximum context length/i, // Mistral
|
||||
CONFIGURED_CONTEXT_SIZE_OVERFLOW_RE, // DS4 server
|
||||
/model_context_window_exceeded/i, // z.ai non-standard finish_reason surfaced as error text
|
||||
/prompt too long; exceeded (?:max )?context length/i, // Ollama explicit overflow error
|
||||
/context[_ ]length[_ ]exceeded/i, // Generic fallback
|
||||
/too many tokens/i, // Generic fallback
|
||||
/token limit exceeded/i, // Generic fallback
|
||||
/^4(?:00|13)\s*(?:status code)?\s*\(no body\)/i, // Cerebras: 400/413 with no body
|
||||
];
|
||||
|
||||
/**
|
||||
* Patterns that indicate non-overflow errors (e.g. rate limiting, server errors).
|
||||
* Error messages matching unknown of these are excluded from overflow detection
|
||||
* even if they also match an OVERFLOW_PATTERN.
|
||||
*
|
||||
* Example: Bedrock formats throttling errors as "ThrottlingException: Too many tokens,
|
||||
* please wait before trying again." which would match the /too many tokens/i overflow
|
||||
* pattern without this exclusion.
|
||||
*/
|
||||
const NON_OVERFLOW_PATTERNS = [
|
||||
/^(Throttling error|Service unavailable):/i, // AWS Bedrock non-overflow errors (human-readable prefixes from formatBedrockError)
|
||||
/rate limit/i, // Generic rate limiting
|
||||
/too many requests/i, // Generic HTTP 429 style
|
||||
];
|
||||
|
||||
function resolveContextInputTokens(message: AssistantMessage): number | undefined {
|
||||
if (message.usage.contextUsage?.state === "available") {
|
||||
return message.usage.contextUsage.promptTokens;
|
||||
}
|
||||
if (message.usage.contextUsage?.state === "unavailable") {
|
||||
return undefined;
|
||||
}
|
||||
return message.usage.input + message.usage.cacheRead;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an assistant message represents a context overflow error.
|
||||
*
|
||||
* This handles two cases:
|
||||
* 1. Error-based overflow: Most providers return stopReason "error" with a
|
||||
* specific error message pattern.
|
||||
* 2. Silent overflow: Some providers accept overflow requests and return
|
||||
* successfully. For these, we check if usage.input exceeds the context window.
|
||||
*
|
||||
* ## Reliability by Provider
|
||||
*
|
||||
* **Reliable detection (returns error with detectable message):**
|
||||
* - Anthropic: "prompt is too long: X tokens > Y maximum" or "request_too_large"
|
||||
* - OpenAI (Completions & Responses): "exceeds the context window" or "exceeds the model's maximum context length of X tokens"
|
||||
* - Google Gemini: "input token count exceeds the maximum"
|
||||
* - xAI (Grok): "maximum prompt length is X but request contains Y"
|
||||
* - Groq: "reduce the length of the messages"
|
||||
* - Cerebras: 400/413 status code (no body)
|
||||
* - Mistral: "Prompt contains X tokens ... too large for model with Y maximum context length"
|
||||
* - OpenRouter (all backends): "maximum context length is X tokens"
|
||||
* - Together AI: "The input (X tokens) is longer than the model's context length (Y tokens)."
|
||||
* - llama.cpp: "exceeds the available context size"
|
||||
* - LM Studio: "greater than the context length"
|
||||
* - Kimi For Coding: "exceeded model token limit: X (requested: Y)"
|
||||
*
|
||||
* **Unreliable detection:**
|
||||
* - z.ai: Sometimes accepts overflow silently (detectable via usage.input > contextWindow),
|
||||
* sometimes returns rate limit errors. Pass contextWindow param to detect silent overflow.
|
||||
* - Xiaomi MiMo: Truncates input to fit contextWindow then returns stopReason "length" with
|
||||
* output=0. Pass contextWindow param to detect via the "filled context + zero output" signal.
|
||||
* - Ollama: May truncate input silently for some setups, but may also return explicit
|
||||
* overflow errors that match the patterns above. Silent truncation still cannot be
|
||||
* detected here because we do not know the expected token count.
|
||||
*
|
||||
* ## Custom Providers
|
||||
*
|
||||
* If you've added custom models via settings.json, this function may not detect
|
||||
* overflow errors from those providers. To add support:
|
||||
*
|
||||
* 1. Send a request that exceeds the model's context window
|
||||
* 2. Check the errorMessage in the response
|
||||
* 3. Create a regex pattern that matches the error
|
||||
* 4. The pattern should be added to OVERFLOW_PATTERNS in this file, or
|
||||
* check the errorMessage yourself before calling this function
|
||||
*
|
||||
* @param message - The assistant message to check
|
||||
* @param contextWindow - Optional context window size for detecting silent overflow (z.ai)
|
||||
* @returns true if the message indicates a context overflow
|
||||
*/
|
||||
export function isContextOverflow(message: AssistantMessage, contextWindow?: number): boolean {
|
||||
// Case 1: Check error message patterns
|
||||
if (message.stopReason === "error" && message.errorMessage) {
|
||||
// Skip messages matching known non-overflow patterns (e.g. throttling / rate-limit)
|
||||
const isNonOverflow = NON_OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!));
|
||||
if (!isNonOverflow && OVERFLOW_PATTERNS.some((p) => p.test(message.errorMessage!))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: Silent overflow (z.ai style) - successful but usage exceeds context
|
||||
if (contextWindow && message.stopReason === "stop") {
|
||||
const inputTokens = resolveContextInputTokens(message);
|
||||
if (inputTokens !== undefined && inputTokens > contextWindow) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: Length-stop overflow (Xiaomi MiMo style) - server truncates oversized input
|
||||
// to fit the context window, leaving no room for output. Returns stopReason "length"
|
||||
// with output=0 and input+cacheRead filling the context window.
|
||||
if (contextWindow && message.stopReason === "length" && message.usage.output === 0) {
|
||||
const inputTokens = resolveContextInputTokens(message);
|
||||
if (inputTokens !== undefined && inputTokens >= contextWindow * 0.99) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
29
packages/ai/src/utils/prompt-cache-stability.ts
Normal file
29
packages/ai/src/utils/prompt-cache-stability.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Prompt-cache normalization helpers. They keep generated prompt sections
|
||||
* deterministic across platform newlines, trailing whitespace, and input
|
||||
* ordering.
|
||||
*/
|
||||
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
/** Normalize structured prompt text before hashing or snapshot comparison. */
|
||||
export function normalizeStructuredPromptSection(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, "\n")
|
||||
.replace(/[ \t]+$/gm, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Normalize, de-dupe, and sort capability ids for stable prompt payloads. */
|
||||
export function normalizePromptCapabilityIds(capabilities: ReadonlyArray<string>): string[] {
|
||||
const seen = new Set<string>();
|
||||
const normalized: string[] = [];
|
||||
for (const capability of capabilities) {
|
||||
const value = normalizeLowercaseStringOrEmpty(normalizeStructuredPromptSection(capability));
|
||||
if (!value || seen.has(value)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(value);
|
||||
normalized.push(value);
|
||||
}
|
||||
return normalized.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
297
packages/ai/src/utils/reasoning-tag-text-partitioner.test.ts
Normal file
297
packages/ai/src/utils/reasoning-tag-text-partitioner.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
// Reasoning tag partitioner tests cover splitting reasoning and visible text segments.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createReasoningTagTextPartitioner } from "./reasoning-tag-text-partitioner.js";
|
||||
|
||||
describe("createReasoningTagTextPartitioner", () => {
|
||||
it("routes split inline reasoning tags away from visible text", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
const deltas = [
|
||||
...partitioner.push("before <thi"),
|
||||
...partitioner.push("nk>hidden"),
|
||||
...partitioner.push("</think> after"),
|
||||
...partitioner.flush(),
|
||||
];
|
||||
|
||||
expect(deltas).toEqual([
|
||||
{ kind: "text", text: "before " },
|
||||
{ kind: "thinking", text: "hidden" },
|
||||
{ kind: "text", text: " after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unterminated reasoning as thinking on flush", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect([...partitioner.push("visible <reasoning>hidden tail"), ...partitioner.flush()]).toEqual(
|
||||
[
|
||||
{ kind: "text", text: "visible " },
|
||||
{ kind: "thinking", text: "hidden tail" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
it("emits ordinary angle-bracket text immediately", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.push("<div>visible")).toEqual([{ kind: "text", text: "<div>visible" }]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports pending partial tags and active reasoning", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.hasPending()).toBe(false);
|
||||
expect(partitioner.push("before <thi")).toEqual([{ kind: "text", text: "before " }]);
|
||||
expect(partitioner.hasPending()).toBe(true);
|
||||
expect(partitioner.push("nk>hidden")).toEqual([{ kind: "thinking", text: "hidden" }]);
|
||||
expect(partitioner.hasPending()).toBe(true);
|
||||
expect(partitioner.isInsideReasoning()).toBe(true);
|
||||
expect(partitioner.push("</think> after")).toEqual([{ kind: "text", text: " after" }]);
|
||||
expect(partitioner.hasPending()).toBe(false);
|
||||
expect(partitioner.isInsideReasoning()).toBe(false);
|
||||
});
|
||||
|
||||
it("holds possible reasoning opens in visible mode until they are resolved", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("before <thi")).toEqual([{ kind: "text", text: "before " }]);
|
||||
expect(partitioner.push("nk>hidden")).toEqual([{ kind: "thinking", text: "hidden" }]);
|
||||
});
|
||||
|
||||
it("strips complete reasoning tags in visible mode", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Use <think>literal</think> here")).toEqual([
|
||||
{ kind: "text", text: "Use " },
|
||||
{ kind: "thinking", text: "literal" },
|
||||
{ kind: "text", text: " here" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps split reasoning tags with attributes out of visible text", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
const deltas = [
|
||||
...partitioner.pushVisible("Before <think "),
|
||||
...partitioner.pushVisible("id='x'>secret</think> after"),
|
||||
...partitioner.flush(),
|
||||
];
|
||||
|
||||
expect(deltas).toEqual([
|
||||
{ kind: "text", text: "Before " },
|
||||
{ kind: "thinking", text: "secret" },
|
||||
{ kind: "text", text: " after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps split antml reasoning tags out of visible text", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
const deltas = [
|
||||
...partitioner.pushVisible("Before <antml:reas"),
|
||||
...partitioner.pushVisible("oning>secret</antml:reasoning> after"),
|
||||
...partitioner.flush(),
|
||||
];
|
||||
|
||||
expect(deltas).toEqual([
|
||||
{ kind: "text", text: "Before " },
|
||||
{ kind: "thinking", text: "secret" },
|
||||
{ kind: "text", text: " after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps split mm reasoning tags out of visible text", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
const deltas = [
|
||||
...partitioner.push("Before <mm:thi"),
|
||||
...partitioner.push("nk>secret</mm:think> after"),
|
||||
...partitioner.flush(),
|
||||
];
|
||||
|
||||
expect(deltas).toEqual([
|
||||
{ kind: "text", text: "Before " },
|
||||
{ kind: "thinking", text: "secret" },
|
||||
{ kind: "text", text: " after" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps nested reasoning hidden until the outer tag closes", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(
|
||||
partitioner.pushVisible("<think>outer <think>inner</think> still outer</think>visible"),
|
||||
).toEqual([
|
||||
{ kind: "thinking", text: "outer inner still outer" },
|
||||
{ kind: "text", text: "visible" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("drops malformed reasoning before orphan close tags in strict mode", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.push("private chain of thought </think> Visible answer")).toEqual([
|
||||
{ kind: "text", text: " Visible answer" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps unmatched close-tag prose visible in visible mode", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Use </think> to close the tag")).toEqual([
|
||||
{ kind: "text", text: "Use </think> to close the tag" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("buffers split orphan close tags until the visible suffix arrives", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.push("private chain of thought </think>")).toEqual([]);
|
||||
expect(partitioner.push(" Visible answer")).toEqual([
|
||||
{ kind: "text", text: " Visible answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("buffers split orphan close tag prefixes with their hidden prefix", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.push("private chain of thought </thi")).toEqual([]);
|
||||
expect(partitioner.push("nk> Visible answer")).toEqual([
|
||||
{ kind: "text", text: " Visible answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps close tags inside hidden code fences private", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("<think>\n```ts\nliteral ")).toEqual([]);
|
||||
expect(partitioner.pushVisible("</think> still private")).toEqual([]);
|
||||
expect(partitioner.flush()).toEqual([
|
||||
{ kind: "thinking", text: "\n```ts\nliteral </think> still private" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("recovers fully wrapped unclosed visible-mode text on flush", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("<think>Visible answer from a malformed local model")).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(partitioner.flush()).toEqual([
|
||||
{ kind: "text", text: "Visible answer from a malformed local model" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps unclosed trailing tags as visible prose in visible mode", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Use <think> only in this mode")).toEqual([
|
||||
{ kind: "text", text: "Use " },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([{ kind: "text", text: "<think> only in this mode" }]);
|
||||
});
|
||||
|
||||
it("does not treat code-span reasoning tag examples as hidden reasoning", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.push("Use `<think>literal</think>` here")).toEqual([
|
||||
{ kind: "text", text: "Use `<think>literal</think>` here" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves split code-span reasoning tag examples when active routing begins", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Use `<thi")).toEqual([{ kind: "text", text: "Use `<thi" }]);
|
||||
expect(partitioner.push("nk>literal</think>` here")).toEqual([
|
||||
{ kind: "text", text: "nk>literal</think>` here" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves code-span reasoning tag examples when the closing backtick arrives later", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Use `<think>literal</think>")).toEqual([
|
||||
{ kind: "text", text: "Use " },
|
||||
]);
|
||||
expect(partitioner.pushVisible("` here")).toEqual([
|
||||
{ kind: "text", text: "`<think>literal</think>` here" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves code-span reasoning tag examples when the stream splits after the opener", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Use `")).toEqual([{ kind: "text", text: "Use `" }]);
|
||||
expect(partitioner.pushVisible("<think>literal</think>` here")).toEqual([
|
||||
{ kind: "text", text: "<think>literal</think>` here" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves multi-backtick code-span reasoning tag examples across chunks", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Use ``<think>")).toEqual([{ kind: "text", text: "Use " }]);
|
||||
expect(partitioner.pushVisible("literal</think>`` here")).toEqual([
|
||||
{ kind: "text", text: "``<think>literal</think>`` here" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("reclassifies reasoning tags inside unclosed inline code on final flush", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Start `unclosed <think>secret</think> end")).toEqual([
|
||||
{ kind: "text", text: "Start " },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([
|
||||
{ kind: "text", text: "`unclosed " },
|
||||
{ kind: "thinking", text: "secret" },
|
||||
{ kind: "text", text: " end" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps buffered unclosed reasoning hidden after strict mode is marked", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("<think>secret")).toEqual([]);
|
||||
partitioner.markStrict();
|
||||
expect(partitioner.flush()).toEqual([{ kind: "thinking", text: "secret" }]);
|
||||
});
|
||||
|
||||
it("preserves fenced reasoning tag examples when the stream splits after the fence", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("Example:\n```")).toEqual([
|
||||
{ kind: "text", text: "Example:\n```" },
|
||||
]);
|
||||
expect(partitioner.pushVisible("\n<think>literal</think>\n```\nDone.")).toEqual([
|
||||
{ kind: "text", text: "\n<think>literal</think>\n```\nDone." },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
|
||||
it("preserves fenced reasoning tag examples when the fence marker is split", () => {
|
||||
const partitioner = createReasoningTagTextPartitioner();
|
||||
|
||||
expect(partitioner.pushVisible("``")).toEqual([]);
|
||||
expect(
|
||||
partitioner.pushVisible(
|
||||
"`xml\n<thinking>literal</thinking>\n```\n<think>secret</think>answer",
|
||||
),
|
||||
).toEqual([
|
||||
{
|
||||
kind: "text",
|
||||
text: "```xml\n<thinking>literal</thinking>\n```\n",
|
||||
},
|
||||
{ kind: "thinking", text: "secret" },
|
||||
{ kind: "text", text: "answer" },
|
||||
]);
|
||||
expect(partitioner.flush()).toEqual([]);
|
||||
});
|
||||
});
|
||||
364
packages/ai/src/utils/reasoning-tag-text-partitioner.ts
Normal file
364
packages/ai/src/utils/reasoning-tag-text-partitioner.ts
Normal file
@@ -0,0 +1,364 @@
|
||||
// Reasoning tag partitioner helpers split text around reasoning tag regions.
|
||||
import {
|
||||
buildCodeSpanIndex,
|
||||
createInlineCodeState,
|
||||
type InlineCodeState,
|
||||
} from "@openclaw/markdown-core/code-spans";
|
||||
import type { FenceScanState } from "@openclaw/markdown-core/fences";
|
||||
|
||||
export type ReasoningTagTextDelta =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "thinking"; text: string };
|
||||
|
||||
const REASONING_TAG_RE =
|
||||
/<\s*(\/?)\s*(?:(?:antml:|mm:)?(?:think(?:ing)?|thought|reasoning)|antthinking)\b[^<>]*>/gi;
|
||||
const REASONING_TAG_NAMES = [
|
||||
"think",
|
||||
"thinking",
|
||||
"thought",
|
||||
"reasoning",
|
||||
"antthinking",
|
||||
"antml:think",
|
||||
"antml:thinking",
|
||||
"antml:thought",
|
||||
"antml:reasoning",
|
||||
"mm:think",
|
||||
"mm:thinking",
|
||||
"mm:thought",
|
||||
"mm:reasoning",
|
||||
] as const;
|
||||
|
||||
export interface ReasoningTagTextPartitioner {
|
||||
markStrict(): void;
|
||||
push(chunk: string): ReasoningTagTextDelta[];
|
||||
pushVisible(chunk: string): ReasoningTagTextDelta[];
|
||||
flush(): ReasoningTagTextDelta[];
|
||||
hasPending(): boolean;
|
||||
isInsideReasoning(): boolean;
|
||||
}
|
||||
|
||||
export function createReasoningTagTextPartitioner(): ReasoningTagTextPartitioner {
|
||||
let buffer = "";
|
||||
let reasoningDepth = 0;
|
||||
let strictMode = false;
|
||||
let emittedVisibleText = false;
|
||||
let inlineCodeState: InlineCodeState = createInlineCodeState();
|
||||
let fenceState: FenceScanState | undefined;
|
||||
let hiddenInlineCodeState: InlineCodeState = createInlineCodeState();
|
||||
let hiddenFenceState: FenceScanState | undefined;
|
||||
let recoverableOpenTagText: string | undefined;
|
||||
|
||||
const consume = (final: boolean, recoverFullUnclosed: boolean): ReasoningTagTextDelta[] => {
|
||||
const output: ReasoningTagTextDelta[] = [];
|
||||
const emit = (kind: ReasoningTagTextDelta["kind"], text: string) => {
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
if (kind === "text" && text.trim().length > 0) {
|
||||
emittedVisibleText = true;
|
||||
}
|
||||
if (kind === "text") {
|
||||
const nextCode = buildCodeSpanIndex(text, inlineCodeState, fenceState);
|
||||
inlineCodeState = nextCode.inlineState;
|
||||
fenceState = nextCode.fenceState;
|
||||
} else {
|
||||
const nextCode = buildCodeSpanIndex(text, hiddenInlineCodeState, hiddenFenceState);
|
||||
hiddenInlineCodeState = nextCode.inlineState;
|
||||
hiddenFenceState = nextCode.fenceState;
|
||||
}
|
||||
const previous = output[output.length - 1];
|
||||
if (previous?.kind === kind) {
|
||||
previous.text += text;
|
||||
return;
|
||||
}
|
||||
output.push({ kind, text });
|
||||
};
|
||||
|
||||
while (buffer) {
|
||||
const activeInlineCodeState = reasoningDepth === 0 ? inlineCodeState : hiddenInlineCodeState;
|
||||
const activeFenceState = reasoningDepth === 0 ? fenceState : hiddenFenceState;
|
||||
const codeSpans = buildCodeSpanIndex(buffer, activeInlineCodeState, activeFenceState);
|
||||
const hasUnclosedCode =
|
||||
reasoningDepth === 0 && Boolean(codeSpans.inlineState.open || codeSpans.fenceState.open);
|
||||
const hasRawReasoning = hasRawReasoningTag(buffer);
|
||||
const tag = findNextReasoningTag(buffer, (index) =>
|
||||
final && hasUnclosedCode && hasRawReasoning ? false : codeSpans.isInside(index),
|
||||
);
|
||||
if (!tag) {
|
||||
if (final) {
|
||||
const recoverAsText =
|
||||
reasoningDepth > 0 && recoverFullUnclosed && !hasRawReasoningCloseTag(buffer);
|
||||
const recoveredText =
|
||||
recoverAsText && recoverableOpenTagText ? recoverableOpenTagText + buffer : buffer;
|
||||
emit(reasoningDepth > 0 && !recoverAsText ? "thinking" : "text", recoveredText);
|
||||
buffer = "";
|
||||
reasoningDepth = 0;
|
||||
recoverableOpenTagText = undefined;
|
||||
return output;
|
||||
}
|
||||
if (
|
||||
reasoningDepth > 0 &&
|
||||
recoverFullUnclosed &&
|
||||
(!emittedVisibleText || recoverableOpenTagText)
|
||||
) {
|
||||
return output;
|
||||
}
|
||||
if (hasUnclosedCode && hasRawReasoning) {
|
||||
const openCodeIndex =
|
||||
inlineCodeState.open || fenceState?.open ? 0 : findOpenCodeContextStart(buffer);
|
||||
if (openCodeIndex !== -1) {
|
||||
emit("text", buffer.slice(0, openCodeIndex));
|
||||
buffer = buffer.slice(openCodeIndex);
|
||||
return output;
|
||||
}
|
||||
}
|
||||
const trailingFenceStart = findTrailingFenceFragmentStart(
|
||||
buffer,
|
||||
activeInlineCodeState,
|
||||
activeFenceState,
|
||||
);
|
||||
if (trailingFenceStart !== -1) {
|
||||
emit(reasoningDepth > 0 ? "thinking" : "text", buffer.slice(0, trailingFenceStart));
|
||||
buffer = buffer.slice(trailingFenceStart);
|
||||
return output;
|
||||
}
|
||||
const keepFrom = reasoningTagPrefixSuffixIndex(buffer, (index) =>
|
||||
codeSpans.isInside(index),
|
||||
);
|
||||
if (keepFrom === -1) {
|
||||
emit(reasoningDepth > 0 ? "thinking" : "text", buffer);
|
||||
buffer = "";
|
||||
return output;
|
||||
}
|
||||
if (
|
||||
reasoningDepth === 0 &&
|
||||
keepFrom > 0 &&
|
||||
buffer.slice(0, keepFrom).trim().length > 0 &&
|
||||
isReasoningCloseTagPrefix(buffer.slice(keepFrom))
|
||||
) {
|
||||
return output;
|
||||
}
|
||||
if (keepFrom > 0) {
|
||||
emit(reasoningDepth > 0 ? "thinking" : "text", buffer.slice(0, keepFrom));
|
||||
buffer = buffer.slice(keepFrom);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
const beforeTag = buffer.slice(0, tag.index);
|
||||
const afterTag = buffer.slice(tag.index + tag.text.length);
|
||||
if (tag.isClose && reasoningDepth === 0) {
|
||||
if (recoverFullUnclosed && beforeTag.trim().length > 0 && afterTag.trim().length > 0) {
|
||||
emit("text", beforeTag + tag.text);
|
||||
buffer = afterTag;
|
||||
continue;
|
||||
}
|
||||
if (beforeTag.trim().length > 0 && afterTag.trim().length === 0 && !final) {
|
||||
return output;
|
||||
}
|
||||
if (beforeTag.trim().length === 0 || afterTag.trim().length === 0) {
|
||||
emit("text", beforeTag);
|
||||
}
|
||||
buffer = afterTag;
|
||||
continue;
|
||||
}
|
||||
|
||||
emit(reasoningDepth > 0 ? "thinking" : "text", buffer.slice(0, tag.index));
|
||||
buffer = afterTag;
|
||||
if (tag.isClose) {
|
||||
reasoningDepth = Math.max(0, reasoningDepth - 1);
|
||||
if (reasoningDepth === 0) {
|
||||
recoverableOpenTagText = undefined;
|
||||
hiddenInlineCodeState = createInlineCodeState();
|
||||
hiddenFenceState = undefined;
|
||||
}
|
||||
} else {
|
||||
if (reasoningDepth === 0) {
|
||||
recoverableOpenTagText = recoverFullUnclosed && emittedVisibleText ? tag.text : undefined;
|
||||
hiddenInlineCodeState = createInlineCodeState();
|
||||
hiddenFenceState = undefined;
|
||||
}
|
||||
reasoningDepth += 1;
|
||||
}
|
||||
}
|
||||
return output;
|
||||
};
|
||||
|
||||
return {
|
||||
markStrict() {
|
||||
strictMode = true;
|
||||
},
|
||||
push(chunk: string) {
|
||||
strictMode = true;
|
||||
buffer += chunk;
|
||||
return consume(false, false);
|
||||
},
|
||||
pushVisible(chunk: string) {
|
||||
buffer += chunk;
|
||||
return consume(false, true);
|
||||
},
|
||||
flush() {
|
||||
return consume(true, !strictMode);
|
||||
},
|
||||
hasPending() {
|
||||
return buffer.length > 0 || reasoningDepth > 0;
|
||||
},
|
||||
isInsideReasoning() {
|
||||
return reasoningDepth > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function hasRawReasoningTag(text: string): boolean {
|
||||
REASONING_TAG_RE.lastIndex = 0;
|
||||
return REASONING_TAG_RE.test(text);
|
||||
}
|
||||
|
||||
function hasRawReasoningCloseTag(text: string): boolean {
|
||||
REASONING_TAG_RE.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = REASONING_TAG_RE.exec(text);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
if (match[1] === "/") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findNextReasoningTag(
|
||||
text: string,
|
||||
isIndexInsideCode: (index: number) => boolean,
|
||||
): { index: number; text: string; isClose: boolean } | null {
|
||||
REASONING_TAG_RE.lastIndex = 0;
|
||||
for (;;) {
|
||||
const match = REASONING_TAG_RE.exec(text);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
if (!isIndexInsideCode(match.index)) {
|
||||
return {
|
||||
index: match.index,
|
||||
text: match[0],
|
||||
isClose: match[1] === "/",
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function reasoningTagPrefixSuffixIndex(
|
||||
text: string,
|
||||
isIndexInsideCode: (index: number) => boolean,
|
||||
): number {
|
||||
for (let index = text.lastIndexOf("<"); index >= 0; ) {
|
||||
if (!isIndexInsideCode(index) && isReasoningTagPrefix(text.slice(index))) {
|
||||
return index;
|
||||
}
|
||||
if (index === 0) {
|
||||
break;
|
||||
}
|
||||
index = text.lastIndexOf("<", index - 1);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
function isReasoningTagPrefix(text: string): boolean {
|
||||
const name = normalizeReasoningTagPrefixName(text);
|
||||
return REASONING_TAG_NAMES.some((tagName) => {
|
||||
if (tagName.startsWith(name)) {
|
||||
return true;
|
||||
}
|
||||
if (!name.startsWith(tagName)) {
|
||||
return false;
|
||||
}
|
||||
const rest = name.slice(tagName.length);
|
||||
return rest.length === 0 || /^[\s/>]/.test(rest);
|
||||
});
|
||||
}
|
||||
|
||||
function isReasoningCloseTagPrefix(text: string): boolean {
|
||||
const normalized = text
|
||||
.replace(/^<\s*/, "<")
|
||||
.replace(/^<\s*\//, "</")
|
||||
.replace(/^<\/\s*/, "</")
|
||||
.toLowerCase();
|
||||
return normalized.startsWith("</") && isReasoningTagPrefix(text);
|
||||
}
|
||||
|
||||
function normalizeReasoningTagPrefixName(text: string): string {
|
||||
const normalized = text
|
||||
.replace(/^<\s*/, "<")
|
||||
.replace(/^<\s*\//, "</")
|
||||
.replace(/^<\/\s*/, "</")
|
||||
.toLowerCase();
|
||||
const rawName = normalized.startsWith("</") ? normalized.slice(2) : normalized.slice(1);
|
||||
return rawName.trimStart();
|
||||
}
|
||||
|
||||
function findOpenCodeContextStart(text: string): number {
|
||||
const fence = findOpenFenceStart(text);
|
||||
const inline = findOpenInlineCodeStart(text);
|
||||
if (fence === -1) {
|
||||
return inline;
|
||||
}
|
||||
if (inline === -1) {
|
||||
return fence;
|
||||
}
|
||||
return Math.min(fence, inline);
|
||||
}
|
||||
|
||||
function findOpenInlineCodeStart(text: string): number {
|
||||
let openStart = -1;
|
||||
let openTicks = 0;
|
||||
let index = 0;
|
||||
while (index < text.length) {
|
||||
if (text[index] !== "`") {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
const runStart = index;
|
||||
let runLength = 0;
|
||||
while (index < text.length && text[index] === "`") {
|
||||
runLength += 1;
|
||||
index += 1;
|
||||
}
|
||||
if (openStart === -1) {
|
||||
openStart = runStart;
|
||||
openTicks = runLength;
|
||||
} else if (runLength === openTicks) {
|
||||
openStart = -1;
|
||||
openTicks = 0;
|
||||
}
|
||||
}
|
||||
return openStart;
|
||||
}
|
||||
|
||||
function findOpenFenceStart(text: string): number {
|
||||
const fenceRe = /(^|\n)(```|~~~)[^\n]*(?:\n|$)/g;
|
||||
let open: { marker: string; index: number } | null = null;
|
||||
for (const match of text.matchAll(fenceRe)) {
|
||||
const index = (match.index ?? 0) + match[1].length;
|
||||
const marker = match[2] ?? "";
|
||||
if (open !== null && open.marker === marker) {
|
||||
open = null;
|
||||
} else if (!open) {
|
||||
open = { marker, index };
|
||||
}
|
||||
}
|
||||
return open?.index ?? -1;
|
||||
}
|
||||
|
||||
function findTrailingFenceFragmentStart(
|
||||
text: string,
|
||||
inlineState: InlineCodeState,
|
||||
fenceState: FenceScanState | undefined,
|
||||
): number {
|
||||
if (inlineState.open || fenceState?.open) {
|
||||
return -1;
|
||||
}
|
||||
const lineStart = Math.max(text.lastIndexOf("\n") + 1, 0);
|
||||
const line = text.slice(lineStart);
|
||||
const match = line.match(/^( {0,3})(`{1,2}|~{1,2})$/);
|
||||
return match ? lineStart : -1;
|
||||
}
|
||||
28
packages/ai/src/utils/sanitize-unicode.ts
Normal file
28
packages/ai/src/utils/sanitize-unicode.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Removes unpaired Unicode surrogate characters from a string.
|
||||
*
|
||||
* Unpaired surrogates (high surrogates 0xD800-0xDBFF without matching low surrogates 0xDC00-0xDFFF,
|
||||
* or vice versa) cause JSON serialization errors in many API providers.
|
||||
*
|
||||
* Valid emoji and other characters outside the Basic Multilingual Plane use properly paired
|
||||
* surrogates and will NOT be affected by this function.
|
||||
*
|
||||
* @param text - The text to sanitize
|
||||
* @returns The sanitized text with unpaired surrogates removed
|
||||
*
|
||||
* @example
|
||||
* // Valid emoji (properly paired surrogates) are preserved
|
||||
* sanitizeSurrogates("Hello 🙈 World") // => "Hello 🙈 World"
|
||||
*
|
||||
* // Unpaired high surrogate is removed
|
||||
* const unpaired = String.fromCharCode(0xD83D); // high surrogate without low
|
||||
* sanitizeSurrogates(`Text ${unpaired} here`) // => "Text here"
|
||||
*/
|
||||
export function sanitizeSurrogates(text: string): string {
|
||||
// Replace unpaired high surrogates (0xD800-0xDBFF not followed by low surrogate)
|
||||
// Replace unpaired low surrogates (0xDC00-0xDFFF not preceded by high surrogate)
|
||||
return text.replace(
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g,
|
||||
"",
|
||||
);
|
||||
}
|
||||
171
packages/ai/src/utils/stream-first-event-timeout.test.ts
Normal file
171
packages/ai/src/utils/stream-first-event-timeout.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createFirstStreamEventAbortController,
|
||||
withFirstStreamEventTimeout,
|
||||
} from "./stream-first-event-timeout.js";
|
||||
|
||||
function createNeverYieldingStream(onReturn?: () => void): AsyncIterable<unknown> {
|
||||
return {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
async next() {
|
||||
return new Promise<IteratorResult<unknown>>(() => {});
|
||||
},
|
||||
async return() {
|
||||
onReturn?.();
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("withFirstStreamEventTimeout", () => {
|
||||
it("fails when the first event never arrives", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(), {
|
||||
provider: "local",
|
||||
api: "openai-completions",
|
||||
model: "test-model",
|
||||
timeoutMs: 5,
|
||||
stage: "completions",
|
||||
});
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const next = expect(iterator.next()).rejects.toThrow(
|
||||
/completions HTTP stream opened but did not deliver a first SSE event within 5ms after streaming headers \(first-event timeout\)/,
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await next;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("calls iterator return on first-event timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const onReturn = vi.fn();
|
||||
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(onReturn), {
|
||||
timeoutMs: 5,
|
||||
});
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const next = iterator.next().catch((error: unknown) => error);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await next;
|
||||
|
||||
expect(onReturn).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("calls iterator return when the consumer closes after the first event", async () => {
|
||||
const onReturn = vi.fn();
|
||||
const source: AsyncIterable<unknown> = {
|
||||
[Symbol.asyncIterator]() {
|
||||
return {
|
||||
async next() {
|
||||
return { done: false, value: "first" };
|
||||
},
|
||||
async return() {
|
||||
onReturn();
|
||||
return { done: true, value: undefined };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
const stream = withFirstStreamEventTimeout(source, { timeoutMs: 5 });
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: "first" });
|
||||
await iterator.return?.();
|
||||
|
||||
expect(onReturn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("aborts the underlying request on first-event timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const abort = vi.fn();
|
||||
const onTimeout = vi.fn();
|
||||
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(), {
|
||||
timeoutMs: 5,
|
||||
abort,
|
||||
onTimeout,
|
||||
});
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const next = iterator.next().catch((error: unknown) => error);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
const error = await next;
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(onTimeout).toHaveBeenCalledWith(error);
|
||||
expect(abort).toHaveBeenCalledWith(error);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("clamps oversized first-event timeouts before scheduling", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const stream = withFirstStreamEventTimeout(createNeverYieldingStream(), {
|
||||
timeoutMs: Number.MAX_SAFE_INTEGER,
|
||||
});
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
const next = expect(iterator.next()).rejects.toThrow(
|
||||
new RegExp(`within ${MAX_TIMER_TIMEOUT_MS}ms`),
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(MAX_TIMER_TIMEOUT_MS);
|
||||
await next;
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("propagates parent aborts through derived first-event signals", () => {
|
||||
const parent = new AbortController();
|
||||
const firstEventAbort = createFirstStreamEventAbortController(parent.signal);
|
||||
|
||||
parent.abort("run-timeout");
|
||||
|
||||
expect(firstEventAbort.signal.aborted).toBe(true);
|
||||
expect(firstEventAbort.signal.reason).toBe("run-timeout");
|
||||
firstEventAbort.dispose();
|
||||
});
|
||||
|
||||
it("passes through events after the first event without adding inter-event timing", async () => {
|
||||
async function* delayedSecondEvent() {
|
||||
yield "first";
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
yield "second";
|
||||
}
|
||||
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const stream = withFirstStreamEventTimeout(delayedSecondEvent(), { timeoutMs: 5 });
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
|
||||
await expect(iterator.next()).resolves.toEqual({ done: false, value: "first" });
|
||||
const second = iterator.next();
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await expect(second).resolves.toEqual({ done: false, value: "second" });
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns the original stream when disabled", () => {
|
||||
const stream = createNeverYieldingStream();
|
||||
expect(withFirstStreamEventTimeout(stream, { timeoutMs: 0 })).toBe(stream);
|
||||
expect(withFirstStreamEventTimeout(stream, { timeoutMs: Number.NaN })).toBe(stream);
|
||||
});
|
||||
});
|
||||
134
packages/ai/src/utils/stream-first-event-timeout.ts
Normal file
134
packages/ai/src/utils/stream-first-event-timeout.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { clampTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion";
|
||||
|
||||
type StreamStage = "responses" | "completions";
|
||||
|
||||
export type FirstStreamEventTimeoutContext = {
|
||||
provider?: string;
|
||||
api?: string;
|
||||
model?: string;
|
||||
timeoutMs: number;
|
||||
stage?: StreamStage;
|
||||
hint?: string;
|
||||
abort?: (reason: Error) => void;
|
||||
onTimeout?: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export type FirstStreamEventInternalOptions = {
|
||||
firstEventTimeoutMs?: number;
|
||||
abortFirstEventStream?: (reason: Error) => void;
|
||||
onFirstEventTimeout?: (reason: Error) => void;
|
||||
};
|
||||
|
||||
export type FirstStreamEventAbortController = {
|
||||
signal: AbortSignal;
|
||||
abort: (reason: Error) => void;
|
||||
dispose: () => void;
|
||||
};
|
||||
|
||||
export function getFirstStreamEventTimeoutMs(options: unknown): number | undefined {
|
||||
return (options as FirstStreamEventInternalOptions | undefined)?.firstEventTimeoutMs;
|
||||
}
|
||||
|
||||
export function getFirstStreamEventTimeoutHandler(
|
||||
options: unknown,
|
||||
): ((reason: Error) => void) | undefined {
|
||||
return (options as FirstStreamEventInternalOptions | undefined)?.onFirstEventTimeout;
|
||||
}
|
||||
|
||||
function formatOptionalField(name: string, value: string | undefined): string {
|
||||
return value ? ` ${name}=${value}` : "";
|
||||
}
|
||||
|
||||
export function createFirstStreamEventTimeoutError(context: FirstStreamEventTimeoutContext): Error {
|
||||
const stage = context.stage ? `${context.stage} ` : "";
|
||||
const details = [
|
||||
formatOptionalField("provider", context.provider),
|
||||
formatOptionalField("api", context.api),
|
||||
formatOptionalField("model", context.model),
|
||||
].join("");
|
||||
return new Error(
|
||||
`${stage}HTTP stream opened but did not deliver a first SSE event within ${context.timeoutMs}ms after streaming headers (first-event timeout).${details}` +
|
||||
(context.hint ? ` ${context.hint}` : ""),
|
||||
);
|
||||
}
|
||||
|
||||
export function createFirstStreamEventAbortController(
|
||||
parentSignal?: AbortSignal,
|
||||
): FirstStreamEventAbortController {
|
||||
const controller = new AbortController();
|
||||
const abortFromParent = () => {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(parentSignal?.reason);
|
||||
}
|
||||
};
|
||||
if (parentSignal?.aborted) {
|
||||
abortFromParent();
|
||||
} else {
|
||||
parentSignal?.addEventListener("abort", abortFromParent, { once: true });
|
||||
}
|
||||
return {
|
||||
signal: controller.signal,
|
||||
abort(reason: Error) {
|
||||
if (!controller.signal.aborted) {
|
||||
controller.abort(reason);
|
||||
}
|
||||
},
|
||||
dispose() {
|
||||
parentSignal?.removeEventListener("abort", abortFromParent);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function withFirstStreamEventTimeout<T>(
|
||||
stream: AsyncIterable<T>,
|
||||
context: FirstStreamEventTimeoutContext,
|
||||
): AsyncIterable<T> {
|
||||
const timeoutMs = clampTimerTimeoutMs(context.timeoutMs);
|
||||
if (timeoutMs === undefined || context.timeoutMs <= 0) {
|
||||
return stream;
|
||||
}
|
||||
const timeoutContext = { ...context, timeoutMs };
|
||||
return {
|
||||
async *[Symbol.asyncIterator]() {
|
||||
const iterator = stream[Symbol.asyncIterator]();
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let completed = false;
|
||||
const clear = () => {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = undefined;
|
||||
}
|
||||
};
|
||||
try {
|
||||
const first = await new Promise<IteratorResult<T>>((resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
const timeoutError = createFirstStreamEventTimeoutError(timeoutContext);
|
||||
timeoutContext.onTimeout?.(timeoutError);
|
||||
timeoutContext.abort?.(timeoutError);
|
||||
reject(timeoutError);
|
||||
}, timeoutMs);
|
||||
timer.unref?.();
|
||||
iterator.next().then(resolve, reject);
|
||||
}).finally(clear);
|
||||
if (first.done) {
|
||||
completed = true;
|
||||
return;
|
||||
}
|
||||
yield first.value;
|
||||
for (;;) {
|
||||
const next = await iterator.next();
|
||||
if (next.done) {
|
||||
completed = true;
|
||||
return;
|
||||
}
|
||||
yield next.value;
|
||||
}
|
||||
} finally {
|
||||
clear();
|
||||
if (!completed) {
|
||||
void iterator.return?.().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
87
packages/ai/src/utils/streaming-byte-guard.ts
Normal file
87
packages/ai/src/utils/streaming-byte-guard.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Bounded SSE / NDJSON stream reader guard.
|
||||
*
|
||||
* Wraps a `ReadableStreamDefaultReader<Uint8Array>` so the caller's existing
|
||||
* chunk-by-chunk parsing logic is unchanged, but accumulated bytes are tracked
|
||||
* against a hard cap. On overflow the underlying reader is cancelled and a
|
||||
* canonical error is thrown. Mirrors the `readResponseWithLimit` / bounded
|
||||
* JSON response pattern (see `src/agents/provider-http-errors.ts`).
|
||||
*
|
||||
* Internal helper for now. If extensions need it, promote to a plugin-SDK
|
||||
* subpath in a separate, dedicated PR with full SDK metadata sync.
|
||||
*/
|
||||
|
||||
export type SseStreamOverflow = {
|
||||
size: number;
|
||||
maxBytes: number;
|
||||
};
|
||||
|
||||
export type ReadSseStreamWithLimitOptions = {
|
||||
maxBytes: number;
|
||||
onOverflow?: (params: SseStreamOverflow) => Error;
|
||||
};
|
||||
|
||||
export type SseByteGuard = {
|
||||
read(): Promise<ReadableStreamReadResult<Uint8Array>>;
|
||||
cancel(reason?: unknown): Promise<void>;
|
||||
totalBytes(): number;
|
||||
overflowed(): boolean;
|
||||
cancelled(): boolean;
|
||||
};
|
||||
|
||||
export function createSseByteGuard(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
opts: ReadSseStreamWithLimitOptions,
|
||||
): SseByteGuard {
|
||||
if (!Number.isFinite(opts.maxBytes) || opts.maxBytes < 0) {
|
||||
throw new RangeError(`maxBytes must be a non-negative finite number: ${opts.maxBytes}`);
|
||||
}
|
||||
const onOverflow =
|
||||
opts.onOverflow ??
|
||||
((params) =>
|
||||
new Error(`SSE stream exceeds ${params.maxBytes} bytes (received ${params.size})`));
|
||||
let total = 0;
|
||||
let overflowedFlag = false;
|
||||
let cancelledFlag = false;
|
||||
return {
|
||||
read: async () => {
|
||||
if (overflowedFlag || cancelledFlag) {
|
||||
return { done: true, value: undefined };
|
||||
}
|
||||
const result = await reader.read();
|
||||
if (result.done) {
|
||||
return result;
|
||||
}
|
||||
const chunkLen = result.value?.byteLength ?? 0;
|
||||
const next = total + chunkLen;
|
||||
if (next > opts.maxBytes) {
|
||||
overflowedFlag = true;
|
||||
cancelledFlag = true;
|
||||
const err = onOverflow({ size: next, maxBytes: opts.maxBytes });
|
||||
try {
|
||||
await reader.cancel(err);
|
||||
} catch {
|
||||
// best-effort cancellation; caller observes the overflow error
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
total = next;
|
||||
return result;
|
||||
},
|
||||
cancel: async (reason?: unknown) => {
|
||||
if (overflowedFlag) {
|
||||
// overflow already set cancelledFlag; do not overwrite
|
||||
return;
|
||||
}
|
||||
cancelledFlag = true;
|
||||
try {
|
||||
await reader.cancel(reason);
|
||||
} catch {
|
||||
// best-effort cancellation
|
||||
}
|
||||
},
|
||||
totalBytes: () => total,
|
||||
overflowed: () => overflowedFlag,
|
||||
cancelled: () => cancelledFlag,
|
||||
};
|
||||
}
|
||||
92
packages/ai/src/utils/system-prompt-cache-boundary.test.ts
Normal file
92
packages/ai/src/utils/system-prompt-cache-boundary.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// System prompt cache-boundary tests cover the internal marker that separates
|
||||
// stable prompt text from dynamic per-turn additions.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
ensureSystemPromptCacheBoundary,
|
||||
prependSystemPromptAdditionAfterCacheBoundary,
|
||||
splitSystemPromptCacheBoundary,
|
||||
stripSystemPromptCacheBoundary,
|
||||
SYSTEM_PROMPT_CACHE_BOUNDARY,
|
||||
} from "./system-prompt-cache-boundary.js";
|
||||
|
||||
describe("system prompt cache boundary helpers", () => {
|
||||
it("splits stable and dynamic prompt regions", () => {
|
||||
expect(
|
||||
splitSystemPromptCacheBoundary(`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`),
|
||||
).toEqual({
|
||||
stablePrefix: "Stable prefix",
|
||||
dynamicSuffix: "Dynamic suffix",
|
||||
});
|
||||
});
|
||||
|
||||
it("strips the internal marker from prompt text", () => {
|
||||
expect(
|
||||
stripSystemPromptCacheBoundary(`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`),
|
||||
).toBe("Stable prefix\nDynamic suffix");
|
||||
});
|
||||
|
||||
it("inserts prompt additions after the cache boundary", () => {
|
||||
expect(
|
||||
prependSystemPromptAdditionAfterCacheBoundary({
|
||||
systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`,
|
||||
systemPromptAddition: "Per-turn lab context",
|
||||
}),
|
||||
).toBe(`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Per-turn lab context\n\nDynamic suffix`);
|
||||
});
|
||||
|
||||
it("normalizes structured additions and dynamic suffix whitespace", () => {
|
||||
expect(
|
||||
prependSystemPromptAdditionAfterCacheBoundary({
|
||||
systemPrompt: `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix \r\n\r\nMore detail \t\r\n`,
|
||||
systemPromptAddition: " Per-turn lab context \r\nSecond line\t\r\n",
|
||||
}),
|
||||
).toBe(
|
||||
`Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Per-turn lab context\nSecond line\n\nDynamic suffix\n\nMore detail`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ensureSystemPromptCacheBoundary", () => {
|
||||
it("returns a marker-bearing prompt unchanged", () => {
|
||||
const prompt = `Stable prefix${SYSTEM_PROMPT_CACHE_BOUNDARY}Dynamic suffix`;
|
||||
expect(ensureSystemPromptCacheBoundary(prompt)).toBe(prompt);
|
||||
});
|
||||
|
||||
it("appends the boundary to a marker-free prompt", () => {
|
||||
expect(ensureSystemPromptCacheBoundary("Marker-free override")).toBe(
|
||||
`Marker-free override${SYSTEM_PROMPT_CACHE_BOUNDARY}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not add a boundary for an empty prompt", () => {
|
||||
expect(ensureSystemPromptCacheBoundary("")).toBe("");
|
||||
expect(ensureSystemPromptCacheBoundary(" \n\t ")).toBe(" \n\t ");
|
||||
});
|
||||
|
||||
it("uses a per-turn addition directly when the base prompt is empty", () => {
|
||||
expect(
|
||||
prependSystemPromptAdditionAfterCacheBoundary({
|
||||
systemPrompt: ensureSystemPromptCacheBoundary(""),
|
||||
systemPromptAddition: "Per-turn media task hint",
|
||||
}),
|
||||
).toBe("Per-turn media task hint");
|
||||
});
|
||||
|
||||
it("is idempotent for a marker-free prompt", () => {
|
||||
const once = ensureSystemPromptCacheBoundary("Marker-free override");
|
||||
expect(ensureSystemPromptCacheBoundary(once)).toBe(once);
|
||||
});
|
||||
|
||||
it("lets a per-turn addition split into the uncached suffix for a marker-free prompt", () => {
|
||||
// Marker-free overrides become stable prefixes; additions stay in the
|
||||
// dynamic suffix so prompt-cache bytes remain deterministic.
|
||||
const result = prependSystemPromptAdditionAfterCacheBoundary({
|
||||
systemPrompt: ensureSystemPromptCacheBoundary("Marker-free override"),
|
||||
systemPromptAddition: "Per-turn media task hint",
|
||||
});
|
||||
expect(splitSystemPromptCacheBoundary(result)).toEqual({
|
||||
stablePrefix: "Marker-free override",
|
||||
dynamicSuffix: "Per-turn media task hint",
|
||||
});
|
||||
});
|
||||
});
|
||||
66
packages/ai/src/utils/system-prompt-cache-boundary.ts
Normal file
66
packages/ai/src/utils/system-prompt-cache-boundary.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* System prompt cache-boundary helpers.
|
||||
*
|
||||
* Keeps stable prompt prefixes separate from dynamic runtime additions for provider prompt caching.
|
||||
*/
|
||||
import { normalizeStructuredPromptSection } from "./prompt-cache-stability.js";
|
||||
|
||||
export const SYSTEM_PROMPT_CACHE_BOUNDARY = "\n<!-- OPENCLAW_CACHE_BOUNDARY -->\n";
|
||||
|
||||
export function stripSystemPromptCacheBoundary(text: string): string {
|
||||
return text.replaceAll(SYSTEM_PROMPT_CACHE_BOUNDARY, "\n");
|
||||
}
|
||||
|
||||
// Append the cache boundary when a prompt has none (e.g. a hook systemPrompt override),
|
||||
// so dynamic additions route into an uncached suffix instead of the cached prefix (#85203).
|
||||
export function ensureSystemPromptCacheBoundary(systemPrompt: string): string {
|
||||
if (systemPrompt.trim().length === 0) {
|
||||
return systemPrompt;
|
||||
}
|
||||
return systemPrompt.includes(SYSTEM_PROMPT_CACHE_BOUNDARY)
|
||||
? systemPrompt
|
||||
: `${systemPrompt}${SYSTEM_PROMPT_CACHE_BOUNDARY}`;
|
||||
}
|
||||
|
||||
export function splitSystemPromptCacheBoundary(
|
||||
text: string,
|
||||
): { stablePrefix: string; dynamicSuffix: string } | undefined {
|
||||
const boundaryIndex = text.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY);
|
||||
if (boundaryIndex === -1) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
stablePrefix: text.slice(0, boundaryIndex).trimEnd(),
|
||||
dynamicSuffix: text.slice(boundaryIndex + SYSTEM_PROMPT_CACHE_BOUNDARY.length).trimStart(),
|
||||
};
|
||||
}
|
||||
|
||||
export function prependSystemPromptAdditionAfterCacheBoundary(params: {
|
||||
systemPrompt: string;
|
||||
systemPromptAddition?: string;
|
||||
}): string {
|
||||
const systemPromptAddition =
|
||||
typeof params.systemPromptAddition === "string"
|
||||
? normalizeStructuredPromptSection(params.systemPromptAddition)
|
||||
: "";
|
||||
if (!systemPromptAddition) {
|
||||
return params.systemPrompt;
|
||||
}
|
||||
if (params.systemPrompt.trim().length === 0) {
|
||||
return systemPromptAddition;
|
||||
}
|
||||
|
||||
const split = splitSystemPromptCacheBoundary(params.systemPrompt);
|
||||
if (!split) {
|
||||
return `${systemPromptAddition}\n\n${params.systemPrompt}`;
|
||||
}
|
||||
|
||||
const dynamicSuffix = split.dynamicSuffix
|
||||
? normalizeStructuredPromptSection(split.dynamicSuffix)
|
||||
: "";
|
||||
if (!dynamicSuffix) {
|
||||
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${systemPromptAddition}`;
|
||||
}
|
||||
|
||||
return `${split.stablePrefix}${SYSTEM_PROMPT_CACHE_BOUNDARY}${systemPromptAddition}\n\n${dynamicSuffix}`;
|
||||
}
|
||||
Reference in New Issue
Block a user