Files
openclaw/packages/ai/src/utils/stream-first-event-timeout.ts
Peter Steinberger 062f88e3e3 refactor: extract reusable AI runtime package (#99059)
* refactor: extract reusable AI runtime package

* refactor: complete AI provider relocation

* refactor: keep llm core internal

* refactor(ai): make @openclaw/ai self-contained with host policy ports

Move pure transport helpers (tool projections, strict-schema normalization,
prompt-cache boundary, stream guards, anthropic/openai compat, request
activity) from src into packages/ai; move utf16-slice into
normalization-core. Inject host policy (guarded fetch, redaction,
strict-tool defaults, diagnostics logging) through AiTransportHost with
inert library defaults installed by src/llm/stream.ts. Narrow the public
barrel to instance-scoped createApiRegistry/createLlmRuntime; the
process-default runtime moves behind internal/ and
registerBuiltInApiProviders takes an explicit registry. Delete the
src/llm/api-registry re-export facade.

* fix(ai): teach node, jiti, and vite resolvers the @openclaw/ai and utf16-slice subpaths

The workspace alias tables in root-alias.cjs, plugin-sdk-native-resolver,
sdk-alias, the shared vitest config, and the Control UI vite config only
knew @openclaw/llm-core; Node-side plugin loading resolved @openclaw/ai
through the pnpm symlink to the unbuilt dist (checks-node-compact CI
failures), and the Control UI build broke on the new
normalization-core/utf16-slice subpath.

* chore(ui): drop leftover service-worker debug logging

* build(release): ship @openclaw/ai with its own shrinkwrap and honest dependency set

packages/ai declares only its six real runtime deps (kysely, chalk, json5,
tslog, zod, fs-safe, and proxyline were never imported); orphaned root deps
removed. generate-npm-shrinkwrap now treats publishable packages/* like
publishable plugins so the AI tarball pins its transitive tree even though
workspace deps are omitted from the root shrinkwrap. knip learns the
package entry points; the tsdown dts neverBundle option moves to its
documented deps.dts home; the README documents the no-semver internal/*
contract and host ports.

* docs(ai): add minimal external-consumer example app

examples/ai-chat consumes only the public @openclaw/ai surface (built dist
via the workspace link): isolated runtime, built-in provider registration,
one streamed completion. Supports Anthropic/OpenAI via env keys and a
keyless local Ollama target; live-verified against Ollama.

* docs(ai): document the @openclaw/ai package and workspace shrinkwrap boundary

* chore(check): include examples/ in duplicate-scan targets

* fix: emit normalization package subpaths

* fix: complete AI package boundary artifacts

* fix: align AI package boundary contracts

* fix(ci): stabilize package release contracts

* test: align documentation contract checks

* test: keep cron docs guard aligned

* test: align restored docs contract guards

* test: follow upstream docs contracts

* docs: drop superseded talk wording
2026-07-05 01:56:40 -04:00

135 lines
4.0 KiB
TypeScript

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);
}
}
},
};
}