mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-19 17:21:38 +00:00
* 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
135 lines
4.0 KiB
TypeScript
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);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|