refactor: extract LLM core packages (#88117)

* refactor: extract llm core packages

* chore: drop generated llm package artifacts

* fix: align llm package export artifacts

* test: fix moving main CI expectations

* fix: align llm core subpath aliases

* fix: use llm package exports

* fix: stabilize llm package boundary artifacts

* fix: sync llm boundary path contract

* test: isolate crabbox provider env

* test: pin crabbox configured-provider cases

* test: apply crabbox lease provider override
This commit is contained in:
Peter Steinberger
2026-05-30 07:45:04 +02:00
committed by GitHub
parent 17e75f8641
commit aa0d6e1bca
52 changed files with 1581 additions and 1508 deletions

View File

@@ -601,7 +601,7 @@ jobs:
uses: actions/cache@v5
with:
path: .artifacts/build-all-cache
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
key: ${{ runner.os }}-build-all-v3-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'npm-shrinkwrap.json', 'packages/plugin-sdk/package.json', 'packages/llm-core/package.json', 'packages/memory-host-sdk/package.json', 'scripts/build-all.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entries.mjs', 'tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'src/plugin-sdk/**', 'packages/llm-core/src/**', 'packages/memory-host-sdk/src/**', 'src/types/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'scripts/copy-export-html-templates.ts', 'scripts/lib/copy-assets.ts', 'src/auto-reply/reply/export-html/**') }}
restore-keys: |
${{ runner.os }}-build-all-v3-
@@ -1403,7 +1403,7 @@ jobs:
packages/plugin-sdk/dist
extensions/*/dist/.boundary-tsc.tsbuildinfo
extensions/*/dist/.boundary-tsc.stamp
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
key: ${{ runner.os }}-extension-package-boundary-v1-${{ hashFiles('tsconfig.json', 'tsconfig.plugin-sdk.dts.json', 'packages/plugin-sdk/tsconfig.json', 'packages/llm-core/package.json', 'scripts/check-extension-package-tsc-boundary.mjs', 'scripts/prepare-extension-package-boundary-artifacts.mjs', 'scripts/write-plugin-sdk-entry-dts.ts', 'scripts/lib/plugin-sdk-entrypoints.json', 'scripts/lib/plugin-sdk-entries.mjs', 'src/plugin-sdk/**', 'src/auto-reply/**', 'packages/llm-core/src/**', 'src/video-generation/dashscope-compatible.ts', 'src/video-generation/types.ts', 'src/types/**', 'extensions/**', 'extensions/tsconfig.package-boundary*.json', 'package.json', 'pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-extension-package-boundary-v1-
@@ -1420,10 +1420,14 @@ jobs:
find src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
find packages/llm-core/src \
-type f \( -name '*.ts' -o -name '*.tsx' -o -name '*.mts' -o -name '*.cts' -o -name '*.js' -o -name '*.mjs' -o -name '*.json' \) \
-exec touch -t 200001010000 {} +
touch -t 200001010000 \
tsconfig.json \
tsconfig.plugin-sdk.dts.json \
packages/plugin-sdk/tsconfig.json \
packages/llm-core/package.json \
scripts/check-extension-package-tsc-boundary.mjs \
scripts/prepare-extension-package-boundary-artifacts.mjs \
scripts/write-plugin-sdk-entry-dts.ts \

View File

@@ -90,6 +90,18 @@
"@openclaw/discord/api.js": ["../dist/plugin-sdk/extensions/discord/api.d.ts"],
"@openclaw/slack/api.js": ["../dist/plugin-sdk/extensions/slack/api.d.ts"],
"@openclaw/whatsapp/api.js": ["../dist/plugin-sdk/extensions/whatsapp/api.d.ts"],
"@openclaw/llm-core": ["../dist/plugin-sdk/packages/llm-core/src/index.d.ts"],
"@openclaw/llm-core/diagnostics": [
"../dist/plugin-sdk/packages/llm-core/src/utils/diagnostics.d.ts"
],
"@openclaw/llm-core/event-stream": [
"../dist/plugin-sdk/packages/llm-core/src/utils/event-stream.d.ts"
],
"@openclaw/llm-core/types": ["../dist/plugin-sdk/packages/llm-core/src/types.d.ts"],
"@openclaw/llm-core/validation": [
"../dist/plugin-sdk/packages/llm-core/src/validation.d.ts"
],
"@openclaw/llm-core/*": ["../dist/plugin-sdk/packages/llm-core/src/*.d.ts"],
"@openclaw/*.js": ["../packages/plugin-sdk/dist/extensions/*.d.ts", "../extensions/*"],
"@openclaw/*": ["../packages/plugin-sdk/dist/extensions/*", "../extensions/*"],
"openclaw/plugin-sdk/qa-channel": ["../dist/plugin-sdk/src/plugin-sdk/qa-channel.d.ts"],

View File

@@ -93,6 +93,24 @@
"../../dist/plugin-sdk/ssrf-runtime.d.ts"
],
"@openclaw/qa-channel/api.js": ["../../dist/plugin-sdk/extensions/qa-channel/api.d.ts"],
"@openclaw/llm-core": [
"../../dist/plugin-sdk/packages/llm-core/src/index.d.ts"
],
"@openclaw/llm-core/diagnostics": [
"../../dist/plugin-sdk/packages/llm-core/src/utils/diagnostics.d.ts"
],
"@openclaw/llm-core/event-stream": [
"../../dist/plugin-sdk/packages/llm-core/src/utils/event-stream.d.ts"
],
"@openclaw/llm-core/types": [
"../../dist/plugin-sdk/packages/llm-core/src/types.d.ts"
],
"@openclaw/llm-core/validation": [
"../../dist/plugin-sdk/packages/llm-core/src/validation.d.ts"
],
"@openclaw/llm-core/*": [
"../../dist/plugin-sdk/packages/llm-core/src/*.d.ts"
],
"@openclaw/*.js": ["../../packages/plugin-sdk/dist/extensions/*.d.ts", "../*"],
"@openclaw/*": ["../*"],
"openclaw/plugin-sdk/qa-channel": [

View File

@@ -115,6 +115,7 @@
}
},
"dependencies": {
"@openclaw/llm-core": "workspace:*",
"ignore": "7.0.5",
"typebox": "1.1.38",
"yaml": "2.9.0"

View File

@@ -3,7 +3,16 @@
* Transforms to Message[] only at the LLM call boundary.
*/
import { type AssistantMessage, type Context, EventStream, type ToolResultMessage } from "./llm.js";
// Keep the runtime class on the package specifier so built agent-core shares
// constructor identity with @openclaw/llm-core; source types keep SDK d.ts bundled.
import { EventStream as LlmEventStream } from "@openclaw/llm-core";
import {
type AssistantMessage,
type Context,
type EventStream,
type ToolResultMessage,
} from "../../llm-core/src/index.js";
import type { EventStream as SourceEventStream } from "../../llm-core/src/index.js";
import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js";
import type {
AgentContext,
@@ -28,6 +37,8 @@ const EMPTY_USAGE = {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
const EventStreamConstructor: typeof SourceEventStream = LlmEventStream;
/**
* Start an agent loop with a new prompt message.
* The prompt is added to the context and events are emitted for it.
@@ -52,11 +63,13 @@ export function agentLoop(
signal,
streamFn,
runtime,
).then((messages) => {
stream.end(messages);
}).catch((error) => {
pushLoopFailure(stream, config, error, signal?.aborted === true);
});
)
.then((messages) => {
stream.end(messages);
})
.catch((error) => {
pushLoopFailure(stream, config, error, signal?.aborted === true);
});
return stream;
}
@@ -95,11 +108,13 @@ export function agentLoopContinue(
signal,
streamFn,
runtime,
).then((messages) => {
stream.end(messages);
}).catch((error) => {
pushLoopFailure(stream, config, error, signal?.aborted === true);
});
)
.then((messages) => {
stream.end(messages);
})
.catch((error) => {
pushLoopFailure(stream, config, error, signal?.aborted === true);
});
return stream;
}
@@ -157,7 +172,7 @@ export async function runAgentLoopContinue(
}
function createAgentStream(): EventStream<AgentEvent, AgentMessage[]> {
return new EventStream<AgentEvent, AgentMessage[]>(
return new EventStreamConstructor<AgentEvent, AgentMessage[]>(
(event: AgentEvent) => event.type === "agent_end",
(event: AgentEvent) => (event.type === "agent_end" ? event.messages : []),
);

View File

@@ -1,4 +1,3 @@
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
import {
type ImageContent,
type Message,
@@ -7,7 +6,8 @@ import {
type TextContent,
type ThinkingBudgets,
type Transport,
} from "./llm.js";
} from "../../llm-core/src/index.js";
import { runAgentLoop, runAgentLoopContinue } from "./agent-loop.js";
import { type AgentCoreStreamRuntimeDeps, resolveAgentCoreStreamFn } from "./runtime-deps.js";
import type {
AfterToolCallContext,

View File

@@ -1,5 +1,10 @@
import {
type AssistantMessage,
type ImageContent,
type Model,
type UserMessage,
} from "../../../llm-core/src/index.js";
import { runAgentLoop } from "../agent-loop.js";
import { type AssistantMessage, type ImageContent, type Model, type UserMessage } from "../llm.js";
import { type AgentCoreRuntimeDeps, resolveAgentCoreStreamFn } from "../runtime-deps.js";
import type {
AgentContext,

View File

@@ -1,4 +1,4 @@
import type { Model, StreamFn } from "../../llm.js";
import type { Model, StreamFn } from "../../../../llm-core/src/index.js";
import {
type AgentCoreCompletionRuntimeDeps,
resolveAgentCoreCompleteFn,

View File

@@ -5,7 +5,7 @@ import type {
SimpleStreamOptions,
StreamFn,
Usage,
} from "../../llm.js";
} from "../../../../llm-core/src/index.js";
import {
type AgentCoreCompletionRuntimeDeps,
resolveAgentCoreCompleteFn,

View File

@@ -1,4 +1,4 @@
import type { Message } from "../../llm.js";
import type { Message } from "../../../../llm-core/src/index.js";
import type { AgentMessage } from "../../types.js";
/** File paths touched by a session branch or compaction range. */

View File

@@ -1,4 +1,4 @@
import type { ImageContent, Message, TextContent } from "../llm.js";
import type { ImageContent, Message, TextContent } from "../../../llm-core/src/index.js";
import type { AgentMessage } from "../types.js";
import { requireSessionTimestampMs } from "./session/timestamps.js";

View File

@@ -1,4 +1,4 @@
import type { ImageContent, TextContent } from "../../llm.js";
import type { ImageContent, TextContent } from "../../../../llm-core/src/index.js";
import type { AgentMessage } from "../../types.js";
import {
createBranchSummaryMessage,

View File

@@ -1,4 +1,3 @@
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.js";
import type {
ImageContent,
Model,
@@ -6,7 +5,8 @@ import type {
StreamFn,
TextContent,
Transport,
} from "../llm.js";
} from "../../../llm-core/src/index.js";
import type { AgentEvent, AgentMessage, AgentTool, QueueMode, ThinkingLevel } from "../index.js";
import type { AgentCoreCompletionRuntimeDeps, AgentCoreRuntimeDeps } from "../runtime-deps.js";
import type { Session } from "./session/session.js";

View File

@@ -1,268 +1 @@
import type { TSchema } from "typebox";
export type Api = string;
export type CacheRetention = "none" | "short" | "long";
export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type ModelThinkingLevel = "off" | ThinkingLevel;
export type MaybePromise<T> = T | Promise<T>;
export interface ProviderResponse {
status: number;
headers: Record<string, string>;
}
export interface ThinkingBudgets {
minimal?: number;
low?: number;
medium?: number;
high?: number;
max?: number;
}
export interface DiagnosticErrorInfo {
name?: string;
message: string;
stack?: string;
code?: string | number;
}
export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: DiagnosticErrorInfo;
details?: Record<string, unknown>;
}
export interface SimpleStreamOptions {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
apiKey?: string;
transport?: Transport;
cacheRetention?: CacheRetention;
sessionId?: string;
onPayload?: (payload: unknown, model: Model) => MaybePromise<unknown>;
onResponse?: (response: ProviderResponse, model: Model) => void | Promise<void>;
headers?: Record<string, string>;
timeoutMs?: number;
maxRetries?: number;
maxRetryDelayMs?: number;
metadata?: Record<string, unknown>;
reasoning?: ThinkingLevel;
thinkingBudgets?: ThinkingBudgets;
}
export interface TextContent {
type: "text";
text: string;
textSignature?: string;
}
export interface ThinkingContent {
type: "thinking";
thinking: string;
thinkingSignature?: string;
redacted?: boolean;
}
export interface ImageContent {
type: "image";
data: string;
mimeType: string;
}
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, unknown>;
thoughtSignature?: string;
executionMode?: "sequential" | "parallel";
}
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
export type StopReason = "stop" | "length" | "toolUse" | "aborted" | "error";
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number;
}
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: string;
model: string;
responseModel?: string;
responseId?: string;
diagnostics?: AssistantMessageDiagnostic[];
stopReason: StopReason;
errorMessage?: string;
timestamp: number;
usage: Usage;
}
export interface ToolResultMessage {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[];
isError: boolean;
details?: unknown;
timestamp: number;
}
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
export interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}
export interface Model<TApi extends Api = Api> {
id: string;
name: string;
api: TApi;
provider: string;
baseUrl: string;
input: ("text" | "image")[];
reasoning: boolean;
thinkingLevelMap?: Partial<Record<ModelThinkingLevel, string | null>>;
contextWindow: number;
maxTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
};
headers?: Record<string, string>;
// Provider-owned compatibility payload; core carries it without inspecting it.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
compat?: any;
}
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
export type AssistantMessageEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| {
type: "done";
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
message: AssistantMessage;
}
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
constructor(
private readonly isComplete: (event: T) => boolean,
private readonly extractResult: (event: T) => R,
) {
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
}
push(event: T): void {
if (this.done) {
return;
}
if (this.isComplete(event)) {
this.done = true;
this.resolveFinalResult(this.extractResult(event));
}
const waiter = this.waiting.shift();
if (waiter) {
waiter({ value: event, done: false });
} else {
this.queue.push(event);
}
}
end(result?: R): void {
this.done = true;
if (result !== undefined) {
this.resolveFinalResult(result);
}
while (this.waiting.length > 0) {
this.waiting.shift()?.({ value: undefined as unknown as T, done: true });
}
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
} else if (this.done) {
return;
} else {
const result = await new Promise<IteratorResult<T>>((resolve) =>
this.waiting.push(resolve),
);
if (result.done) {
return;
}
yield result.value;
}
}
}
result(): Promise<R> {
return this.finalResultPromise;
}
}
export interface AssistantMessageEventStream extends AsyncIterable<AssistantMessageEvent> {
result(): Promise<AssistantMessage>;
}
export type StreamFn = (
model: Model,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStream | Promise<AssistantMessageEventStream>;
export type CompleteSimpleFn = (
model: Model,
context: Pick<Context, "systemPrompt" | "messages">,
options?: SimpleStreamOptions,
) => Promise<AssistantMessage>;
export type ValidateToolArgumentsFn = (tool: Tool, toolCall: ToolCall) => unknown;
export * from "@openclaw/llm-core";

View File

@@ -1,4 +1,4 @@
import type { CompleteSimpleFn, StreamFn } from "./llm.js";
import type { CompleteSimpleFn, StreamFn } from "../../llm-core/src/index.js";
export interface AgentCoreRuntimeDeps {
streamSimple: StreamFn;

View File

@@ -10,7 +10,7 @@ import type {
TextContent,
Tool,
ToolResultMessage,
} from "./llm.js";
} from "../../llm-core/src/index.js";
/**
* Stream function used by the agent loop.

View File

@@ -1,324 +1 @@
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { Value } from "typebox/value";
import type { Tool, ToolCall } from "./llm.js";
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
interface JsonSchemaObject {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
items?: JsonSchemaObject | JsonSchemaObject[];
additionalProperties?: boolean | JsonSchemaObject;
allOf?: JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
return isRecord(value);
}
function hasTypeBoxMetadata(schema: unknown): boolean {
return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND);
}
function getSchemaTypes(schema: JsonSchemaObject): string[] {
if (typeof schema.type === "string") {
return [schema.type];
}
if (Array.isArray(schema.type)) {
return schema.type.filter((type): type is string => typeof type === "string");
}
return [];
}
function matchesJsonType(value: unknown, type: string): boolean {
switch (type) {
case "number":
return typeof value === "number";
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":
return typeof value === "boolean";
case "string":
return typeof value === "string";
case "null":
return value === null;
case "array":
return Array.isArray(value);
case "object":
return isRecord(value) && !Array.isArray(value);
default:
return false;
}
}
function isValidatorSchema(value: unknown): value is Tool["parameters"] {
return isRecord(value);
}
const JSON_NUMBER_TOKEN_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/iu;
function parseJsonNumberString(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed || !JSON_NUMBER_TOKEN_RE.test(trimmed)) {
return undefined;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseJsonIntegerString(value: string): number | undefined {
const parsed = parseJsonNumberString(value);
return parsed !== undefined && Number.isSafeInteger(parsed) ? parsed : undefined;
}
function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType<typeof Compile> | undefined {
if (!isValidatorSchema(schema)) {
return undefined;
}
try {
return getValidator(schema);
} catch {
return undefined;
}
}
function coercePrimitiveByType(value: unknown, type: string): unknown {
switch (type) {
case "number": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = parseJsonNumberString(value);
if (parsed !== undefined) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "integer": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = parseJsonIntegerString(value);
if (parsed !== undefined) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "boolean": {
if (value === null) {
return false;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
if (typeof value === "number") {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
}
return value;
}
case "string": {
if (value === null) {
return "";
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return value;
}
case "null": {
if (value === "" || value === 0 || value === false) {
return null;
}
return value;
}
default:
return value;
}
}
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
const properties = schema.properties;
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
if (properties) {
for (const [key, propertySchema] of Object.entries(properties)) {
if (key in value) {
value[key] = coerceWithJsonSchema(value[key], propertySchema);
}
}
}
if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) {
for (const [key, propertyValue] of Object.entries(value)) {
if (!definedKeys.has(key)) {
value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
}
}
}
}
function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void {
if (Array.isArray(schema.items)) {
for (let index = 0; index < value.length; index++) {
const itemSchema = schema.items[index];
if (itemSchema) {
value[index] = coerceWithJsonSchema(value[index], itemSchema);
}
}
return;
}
if (isJsonSchemaObject(schema.items)) {
for (let index = 0; index < value.length; index++) {
value[index] = coerceWithJsonSchema(value[index], schema.items);
}
}
}
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
for (const schema of schemas) {
const candidate = structuredClone(value);
const coerced = coerceWithJsonSchema(candidate, schema);
const validator = getSubSchemaValidator(schema);
if (validator?.Check(coerced)) {
return coerced;
}
}
return value;
}
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
let nextValue = value;
if (Array.isArray(schema.allOf)) {
for (const nested of schema.allOf) {
nextValue = coerceWithJsonSchema(nextValue, nested);
}
}
if (Array.isArray(schema.anyOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
}
if (Array.isArray(schema.oneOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
}
const schemaTypes = getSchemaTypes(schema);
const matchesUnionMember =
schemaTypes.length > 1 &&
schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
if (schemaTypes.length > 0 && !matchesUnionMember) {
for (const schemaType of schemaTypes) {
const candidate = coercePrimitiveByType(nextValue, schemaType);
if (candidate !== nextValue) {
nextValue = candidate;
break;
}
}
}
if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) {
applySchemaObjectCoercion(nextValue, schema);
}
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
applySchemaArrayCoercion(nextValue, schema);
}
return nextValue;
}
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
const key = schema as object;
const cached = validatorCache.get(key);
if (cached) {
return cached;
}
const validator = Compile(schema);
validatorCache.set(key, validator);
return validator;
}
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperty = (error.params as { requiredProperties?: string[] })
.requiredProperties?.[0];
if (requiredProperty) {
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
}
}
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return path || "root";
}
export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown {
const tool = tools.find((t) => t.name === toolCall.name);
if (!tool) {
throw new Error(`Tool "${toolCall.name}" not found`);
}
return validateToolArguments(tool, toolCall);
}
export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown {
const args = structuredClone(toolCall.arguments);
Value.Convert(tool.parameters, args);
const validator = getValidator(tool.parameters);
if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) {
const coerced = coerceWithJsonSchema(args, tool.parameters);
if (coerced !== args) {
if (isRecord(args) && isRecord(coerced)) {
for (const key of Object.keys(args)) {
delete args[key];
}
Object.assign(args, coerced);
} else {
return validator.Check(coerced) ? coerced : args;
}
}
}
if (validator.Check(args)) {
return args;
}
const errors =
validator
.Errors(args)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown validation error";
throw new Error(
`Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`,
);
}
export { validateToolArguments, validateToolCall } from "@openclaw/llm-core";

View File

@@ -0,0 +1,41 @@
{
"name": "@openclaw/llm-core",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./types": {
"types": "./dist/types.d.mts",
"import": "./dist/types.mjs",
"default": "./dist/types.mjs"
},
"./diagnostics": {
"types": "./dist/utils/diagnostics.d.mts",
"import": "./dist/utils/diagnostics.mjs",
"default": "./dist/utils/diagnostics.mjs"
},
"./event-stream": {
"types": "./dist/utils/event-stream.d.mts",
"import": "./dist/utils/event-stream.mjs",
"default": "./dist/utils/event-stream.mjs"
},
"./validation": {
"types": "./dist/validation.d.mts",
"import": "./dist/validation.mjs",
"default": "./dist/validation.mjs"
}
},
"dependencies": {
"typebox": "1.1.38"
}
}

View File

@@ -0,0 +1,4 @@
export * from "./types.js";
export * from "./utils/diagnostics.js";
export * from "./utils/event-stream.js";
export * from "./validation.js";

View File

@@ -0,0 +1,582 @@
export type { AssistantMessageDiagnostic, DiagnosticErrorInfo } from "./utils/diagnostics.js";
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.js";
export type KnownApi =
| "openai-completions"
| "mistral-conversations"
| "openai-responses"
| "azure-openai-responses"
| "openai-codex-responses"
| "anthropic-messages"
| "bedrock-converse-stream"
| "google-generative-ai"
| "google-vertex";
export type Api = KnownApi | (string & {});
export type KnownImagesApi = "openrouter-images";
export type ImagesApi = KnownImagesApi | (string & {});
export type Provider = string;
export type KnownImagesProvider = "openrouter";
export type ImagesProvider = string;
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type ModelThinkingLevel = "off" | ThinkingLevel;
export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>;
/** Token budgets for each thinking level (token-based providers only) */
export interface ThinkingBudgets {
minimal?: number;
low?: number;
medium?: number;
high?: number;
max?: number;
}
// Base options all providers share
export type CacheRetention = "none" | "short" | "long";
export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
export type MaybePromise<T> = T | Promise<T>;
export interface ProviderResponse {
status: number;
headers: Record<string, string>;
}
export interface StreamOptions {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
apiKey?: string;
/**
* Preferred transport for providers that support multiple transports.
* Providers that do not support this option ignore it.
*/
transport?: Transport;
/**
* Prompt cache retention preference. Providers map this to their supported values.
* Default: "short".
*/
cacheRetention?: CacheRetention;
/**
* Optional session identifier for providers that support session-based caching.
* Providers can use this to enable prompt caching, request routing, or other
* session-aware features. Ignored by providers that don't support it.
*/
sessionId?: string;
/**
* Optional provider prompt-cache affinity key, distinct from transcript/session identity.
* Providers that do not support separate cache affinity ignore it.
*/
promptCacheKey?: string;
/**
* Optional callback for inspecting or replacing provider payloads before sending.
* Return undefined to keep the payload unchanged.
*/
onPayload?: (payload: unknown, model: Model) => MaybePromise<unknown>;
/**
* Optional callback invoked after an HTTP response is received and before
* its body stream is consumed.
*/
onResponse?: (response: ProviderResponse, model: Model) => void | Promise<void>;
/**
* Optional custom HTTP headers to include in API requests.
* Merged with provider defaults; can override default headers.
* Not supported by all providers (e.g., AWS Bedrock uses SDK auth).
*/
headers?: Record<string, string>;
/**
* HTTP request timeout in milliseconds for providers/SDKs that support it.
* For example, OpenAI and Anthropic SDK clients default to 10 minutes.
*/
timeoutMs?: number;
/**
* Maximum retry attempts for providers/SDKs that support client-side retries.
* For example, OpenAI and Anthropic SDK clients default to 2.
*/
maxRetries?: number;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately
* with an error containing the requested delay, allowing higher-level retry logic
* to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Optional metadata to include in API requests.
* Providers extract the fields they understand and ignore the rest.
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
*/
metadata?: Record<string, unknown>;
}
export type ProviderStreamOptions = StreamOptions & Record<string, unknown>;
export interface ImagesOptions {
signal?: AbortSignal;
apiKey?: string;
/**
* Optional callback for inspecting or replacing provider payloads before sending.
* Return undefined to keep the payload unchanged.
*/
onPayload?: (payload: unknown, model: ImagesModel) => MaybePromise<unknown>;
/**
* Optional callback invoked after an HTTP response is received.
*/
onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise<void>;
/**
* Optional custom HTTP headers to include in API requests.
* Merged with provider defaults; can override default headers.
*/
headers?: Record<string, string>;
/**
* HTTP request timeout in milliseconds for providers/SDKs that support it.
*/
timeoutMs?: number;
/**
* Maximum retry attempts for providers/SDKs that support client-side retries.
*/
maxRetries?: number;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately
* with an error containing the requested delay, allowing higher-level retry logic
* to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Optional metadata to include in API requests.
* Providers extract the fields they understand and ignore the rest.
*/
metadata?: Record<string, unknown>;
}
export type ProviderImagesOptions = ImagesOptions & Record<string, unknown>;
// Unified options with reasoning passed to streamSimple() and completeSimple()
export interface SimpleStreamOptions extends StreamOptions {
reasoning?: ThinkingLevel;
/** Custom token budgets for thinking levels (token-based providers only) */
thinkingBudgets?: ThinkingBudgets;
}
// Generic StreamFunction with typed options.
//
// Contract:
// - Must return an AssistantMessageEventStream.
// - Once invoked, request/model/runtime failures should be encoded in the
// returned stream, not thrown.
// - Error termination must produce an AssistantMessage with stopReason
// "error" or "aborted" and errorMessage, emitted via the stream protocol.
export type StreamFunction<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
> = (
model: Model<TApi>,
context: Context,
options?: TOptions,
) => AssistantMessageEventStreamContract;
export type ImagesFunction<
TApi extends ImagesApi = ImagesApi,
TOptions extends ImagesOptions = ImagesOptions,
> = (
model: ImagesModel<TApi>,
context: ImagesContext,
options?: TOptions,
) => Promise<AssistantImages>;
export interface TextSignatureV1 {
v: 1;
id: string;
phase?: "commentary" | "final_answer";
}
export interface TextContent {
type: "text";
text: string;
textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)
}
export interface ThinkingContent {
type: "thinking";
thinking: string;
thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID
/** When true, the thinking content was redacted by safety filters. The opaque
* encrypted payload is stored in `thinkingSignature` so it can be passed back
* to the API for multi-turn continuity. */
redacted?: boolean;
}
export interface ImageContent {
type: "image";
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/jpeg", "image/png"
}
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, unknown>;
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
executionMode?: "sequential" | "parallel";
}
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number; // Unix timestamp in milliseconds
}
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: Provider;
model: string;
responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`)
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries.
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
timestamp: number; // Unix timestamp in milliseconds
}
export interface ToolResultMessage<TDetails = unknown> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[]; // Supports text and images
details?: TDetails;
isError: boolean;
timestamp: number; // Unix timestamp in milliseconds
}
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
export type ImagesInputContent = TextContent | ImageContent;
export type ImagesOutputContent = TextContent | ImageContent;
export interface ImagesContext {
input: ImagesInputContent[];
}
export type ImagesStopReason = "stop" | "error" | "aborted";
export interface AssistantImages {
api: ImagesApi;
provider: ImagesProvider;
model: string;
output: ImagesOutputContent[];
responseId?: string;
usage?: Usage;
stopReason: ImagesStopReason;
errorMessage?: string;
timestamp: number; // Unix timestamp in milliseconds
}
import type { TSchema } from "typebox";
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
export interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}
/**
* Event protocol for AssistantMessageEventStream.
*
* Streams should emit `start` before partial updates, then terminate with either:
* - `done` carrying the final successful AssistantMessage, or
* - `error` carrying the final AssistantMessage with stopReason "error" or "aborted"
* and errorMessage.
*/
export type AssistantMessageEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| {
type: "done";
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
message: AssistantMessage;
}
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
export interface AssistantMessageEventStreamContract extends AsyncIterable<AssistantMessageEvent> {
push(event: AssistantMessageEvent): void;
end(result?: AssistantMessage): void;
result(): Promise<AssistantMessage>;
}
export interface AssistantMessageEventStreamLike extends AsyncIterable<AssistantMessageEvent> {
result(): Promise<AssistantMessage>;
}
/**
* Compatibility settings for OpenAI-compatible completions APIs.
* Use this to override URL-based auto-detection for custom providers.
*/
export interface OpenAICompletionsCompat {
/** Whether the provider supports the `store` field. Default: auto-detected from URL. */
supportsStore?: boolean;
/** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */
supportsDeveloperRole?: boolean;
/** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */
supportsReasoningEffort?: boolean;
/** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */
supportsUsageInStreaming?: boolean;
/** Which field to use for max tokens. Default: auto-detected from URL. */
maxTokensField?: "max_completion_tokens" | "max_tokens";
/** Whether tool results require the `name` field. Default: auto-detected from URL. */
requiresToolResultName?: boolean;
/** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */
requiresAssistantAfterToolResult?: boolean;
/** Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. */
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
| "deepseek"
| "together"
| "zai"
| "qwen"
| "qwen-chat-template";
/** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
vercelGatewayRouting?: VercelGatewayRouting;
/** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */
zaiToolStream?: boolean;
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
supportsStrictMode?: boolean;
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */
cacheControlFormat?: "anthropic";
/** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */
sendSessionAffinityHeaders?: boolean;
/** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */
supportsLongCacheRetention?: boolean;
}
/** Compatibility settings for OpenAI Responses APIs. */
export interface OpenAIResponsesCompat {
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
sendSessionIdHeader?: boolean;
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
supportsLongCacheRetention?: boolean;
}
/** Compatibility settings for Anthropic Messages-compatible APIs. */
export interface AnthropicMessagesCompat {
/**
* Whether the provider accepts per-tool `eager_input_streaming`.
* When false, the Anthropic provider omits `tools[].eager_input_streaming`
* and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header
* for tool-enabled requests.
* Default: true.
*/
supportsEagerToolInputStreaming?: boolean;
/** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */
supportsLongCacheRetention?: boolean;
/**
* Whether to send the `x-session-affinity` header from `options.sessionId`
* when caching is enabled. Required for providers like Fireworks that use
* session affinity for prompt cache routing (requests to the same replica
* maximize cache hits).
* Default: false.
*/
sendSessionAffinityHeaders?: boolean;
/**
* Whether the provider supports Anthropic-style `cache_control` markers on
* tool definitions. When false, `cache_control` is omitted from tool params.
* Some Anthropic-compatible providers (e.g., Fireworks) do not support this
* field on tools and may reject or ignore it.
* Default: true.
*/
supportsCacheControlOnTools?: boolean;
}
/**
* OpenRouter provider routing preferences.
* Controls which upstream providers OpenRouter routes requests to.
* Sent as the `provider` field in the OpenRouter API request body.
* @see https://openrouter.ai/docs/guides/routing/provider-selection
*/
export interface OpenRouterRouting {
/** Whether to allow backup providers to serve requests. Default: true. */
allow_fallbacks?: boolean;
/** Whether to filter providers to only those that support all parameters in the request. Default: false. */
require_parameters?: boolean;
/** Data collection setting. "allow" (default): allow providers that may store/train on data. "deny": only use providers that don't collect user data. */
data_collection?: "deny" | "allow";
/** Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. */
zdr?: boolean;
/** Whether to restrict routing to only models that allow text distillation. */
enforce_distillable_text?: boolean;
/** An ordered list of provider names/slugs to try in sequence, falling back to the next if unavailable. */
order?: string[];
/** List of provider names/slugs to exclusively allow for this request. */
only?: string[];
/** List of provider names/slugs to skip for this request. */
ignore?: string[];
/** A list of quantization levels to filter providers by (e.g., ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"]). */
quantizations?: string[];
/** Sorting strategy. Can be a string (e.g., "price", "throughput", "latency") or an object with `by` and `partition`. */
sort?:
| string
| {
/** The sorting metric: "price", "throughput", "latency". */
by?: string;
/** Partitioning strategy: "model" (default) or "none". */
partition?: string | null;
};
/** Maximum price per million tokens (USD). */
max_price?: {
/** Price per million prompt tokens. */
prompt?: number | string;
/** Price per million completion tokens. */
completion?: number | string;
/** Price per image. */
image?: number | string;
/** Price per audio unit. */
audio?: number | string;
/** Price per request. */
request?: number | string;
};
/** Preferred minimum throughput (tokens/second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */
preferred_min_throughput?:
| number
| {
/** Minimum tokens/second at the 50th percentile. */
p50?: number;
/** Minimum tokens/second at the 75th percentile. */
p75?: number;
/** Minimum tokens/second at the 90th percentile. */
p90?: number;
/** Minimum tokens/second at the 99th percentile. */
p99?: number;
};
/** Preferred maximum latency (seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */
preferred_max_latency?:
| number
| {
/** Maximum latency in seconds at the 50th percentile. */
p50?: number;
/** Maximum latency in seconds at the 75th percentile. */
p75?: number;
/** Maximum latency in seconds at the 90th percentile. */
p90?: number;
/** Maximum latency in seconds at the 99th percentile. */
p99?: number;
};
}
/**
* Vercel AI Gateway routing preferences.
* Controls which upstream providers the gateway routes requests to.
* @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options
*/
export interface VercelGatewayRouting {
/** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */
only?: string[];
/** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */
order?: string[];
}
// Model interface for the unified model system
export interface Model<TApi extends Api = Api> {
id: string;
name: string;
api: TApi;
provider: Provider;
baseUrl: string;
reasoning: boolean;
/**
* Maps OpenClaw thinking levels to provider/model-specific values.
* Missing keys use provider defaults. null marks a level as unsupported.
*/
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
cost: {
input: number; // $/million tokens
output: number; // $/million tokens
cacheRead: number; // $/million tokens
cacheWrite: number; // $/million tokens
};
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions"
? OpenAICompletionsCompat
: TApi extends "openai-responses"
? OpenAIResponsesCompat
: TApi extends "anthropic-messages"
? AnthropicMessagesCompat
: never;
}
export interface ImagesModel<TApi extends ImagesApi = ImagesApi> extends Omit<
Model,
"api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"
> {
api: TApi;
provider: ImagesProvider;
output: ("text" | "image")[];
}
export type StreamFn = (
model: Model,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStreamLike | Promise<AssistantMessageEventStreamLike>;
export type CompleteSimpleFn = (
model: Model,
context: Pick<Context, "systemPrompt" | "messages">,
options?: SimpleStreamOptions,
) => Promise<AssistantMessage>;
export type ValidateToolArgumentsFn = (tool: Tool, toolCall: ToolCall) => unknown;

View File

@@ -0,0 +1,51 @@
export interface DiagnosticErrorInfo {
name?: string;
message: string;
stack?: string;
code?: string | number;
}
export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: DiagnosticErrorInfo;
details?: Record<string, unknown>;
}
export function formatThrownValue(value: unknown): string {
if (value instanceof Error) {
return value.message || value.name;
}
if (typeof value === "string") {
return value;
}
return String(value);
}
export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo {
if (!(error instanceof Error)) {
return { name: "ThrownValue", message: formatThrownValue(error) };
}
const code = (error as Error & { code?: unknown }).code;
return {
name: error.name || undefined,
message: error.message || error.name,
stack: error.stack,
code: typeof code === "string" || typeof code === "number" ? code : undefined,
};
}
export function createAssistantMessageDiagnostic(
type: string,
error: unknown,
details?: Record<string, unknown>,
): AssistantMessageDiagnostic {
return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details };
}
export function appendAssistantMessageDiagnostic(
message: { diagnostics?: AssistantMessageDiagnostic[] },
diagnostic: AssistantMessageDiagnostic,
): void {
message.diagnostics = [...(message.diagnostics ?? []), diagnostic];
}

View File

@@ -0,0 +1,101 @@
import type {
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStreamContract,
} from "../types.js";
// Generic event stream class for async iteration
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
this.isComplete = isComplete;
this.extractResult = extractResult;
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
}
push(event: T): void {
if (this.done) {
return;
}
if (this.isComplete(event)) {
this.done = true;
this.resolveFinalResult(this.extractResult(event));
}
// Deliver to waiting consumer or queue it
const waiter = this.waiting.shift();
if (waiter) {
waiter({ value: event, done: false });
} else {
this.queue.push(event);
}
}
end(result?: R): void {
this.done = true;
if (result !== undefined) {
this.resolveFinalResult(result);
}
// Notify all waiting consumers that we're done
while (this.waiting.length > 0) {
const waiter = this.waiting.shift()!;
waiter({ value: undefined as unknown, done: true });
}
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
} else if (this.done) {
return;
} else {
const result = await new Promise<IteratorResult<T>>((resolve) =>
this.waiting.push(resolve),
);
if (result.done) {
return;
}
yield result.value;
}
}
}
result(): Promise<R> {
return this.finalResultPromise;
}
}
export class AssistantMessageEventStream
extends EventStream<AssistantMessageEvent, AssistantMessage>
implements AssistantMessageEventStreamContract
{
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") {
return event.message;
} else if (event.type === "error") {
return event.error;
}
throw new Error("Unexpected event type for final result");
},
);
}
}
/** Factory function for AssistantMessageEventStream (for use in extensions) */
export function createAssistantMessageEventStream(): AssistantMessageEventStream {
return new AssistantMessageEventStream();
}

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import type { Tool } from "./llm.js";
import type { Tool } from "./types.js";
import { validateToolArguments } from "./validation.js";
const decimalTool = {

View File

@@ -0,0 +1,324 @@
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { Value } from "typebox/value";
import type { Tool, ToolCall } from "./types.js";
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
interface JsonSchemaObject {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
items?: JsonSchemaObject | JsonSchemaObject[];
additionalProperties?: boolean | JsonSchemaObject;
allOf?: JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
return isRecord(value);
}
function hasTypeBoxMetadata(schema: unknown): boolean {
return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND);
}
function getSchemaTypes(schema: JsonSchemaObject): string[] {
if (typeof schema.type === "string") {
return [schema.type];
}
if (Array.isArray(schema.type)) {
return schema.type.filter((type): type is string => typeof type === "string");
}
return [];
}
function matchesJsonType(value: unknown, type: string): boolean {
switch (type) {
case "number":
return typeof value === "number";
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":
return typeof value === "boolean";
case "string":
return typeof value === "string";
case "null":
return value === null;
case "array":
return Array.isArray(value);
case "object":
return isRecord(value) && !Array.isArray(value);
default:
return false;
}
}
function isValidatorSchema(value: unknown): value is Tool["parameters"] {
return isRecord(value);
}
const JSON_NUMBER_TOKEN_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/iu;
function parseJsonNumberString(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed || !JSON_NUMBER_TOKEN_RE.test(trimmed)) {
return undefined;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseJsonIntegerString(value: string): number | undefined {
const parsed = parseJsonNumberString(value);
return parsed !== undefined && Number.isSafeInteger(parsed) ? parsed : undefined;
}
function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType<typeof Compile> | undefined {
if (!isValidatorSchema(schema)) {
return undefined;
}
try {
return getValidator(schema);
} catch {
return undefined;
}
}
function coercePrimitiveByType(value: unknown, type: string): unknown {
switch (type) {
case "number": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = parseJsonNumberString(value);
if (parsed !== undefined) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "integer": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = parseJsonIntegerString(value);
if (parsed !== undefined) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "boolean": {
if (value === null) {
return false;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
if (typeof value === "number") {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
}
return value;
}
case "string": {
if (value === null) {
return "";
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return value;
}
case "null": {
if (value === "" || value === 0 || value === false) {
return null;
}
return value;
}
default:
return value;
}
}
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
const properties = schema.properties;
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
if (properties) {
for (const [key, propertySchema] of Object.entries(properties)) {
if (key in value) {
value[key] = coerceWithJsonSchema(value[key], propertySchema);
}
}
}
if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) {
for (const [key, propertyValue] of Object.entries(value)) {
if (!definedKeys.has(key)) {
value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
}
}
}
}
function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void {
if (Array.isArray(schema.items)) {
for (let index = 0; index < value.length; index++) {
const itemSchema = schema.items[index];
if (itemSchema) {
value[index] = coerceWithJsonSchema(value[index], itemSchema);
}
}
return;
}
if (isJsonSchemaObject(schema.items)) {
for (let index = 0; index < value.length; index++) {
value[index] = coerceWithJsonSchema(value[index], schema.items);
}
}
}
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
for (const schema of schemas) {
const candidate = structuredClone(value);
const coerced = coerceWithJsonSchema(candidate, schema);
const validator = getSubSchemaValidator(schema);
if (validator?.Check(coerced)) {
return coerced;
}
}
return value;
}
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
let nextValue = value;
if (Array.isArray(schema.allOf)) {
for (const nested of schema.allOf) {
nextValue = coerceWithJsonSchema(nextValue, nested);
}
}
if (Array.isArray(schema.anyOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
}
if (Array.isArray(schema.oneOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
}
const schemaTypes = getSchemaTypes(schema);
const matchesUnionMember =
schemaTypes.length > 1 &&
schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
if (schemaTypes.length > 0 && !matchesUnionMember) {
for (const schemaType of schemaTypes) {
const candidate = coercePrimitiveByType(nextValue, schemaType);
if (candidate !== nextValue) {
nextValue = candidate;
break;
}
}
}
if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) {
applySchemaObjectCoercion(nextValue, schema);
}
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
applySchemaArrayCoercion(nextValue, schema);
}
return nextValue;
}
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
const key = schema as object;
const cached = validatorCache.get(key);
if (cached) {
return cached;
}
const validator = Compile(schema);
validatorCache.set(key, validator);
return validator;
}
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperty = (error.params as { requiredProperties?: string[] })
.requiredProperties?.[0];
if (requiredProperty) {
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
}
}
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return path || "root";
}
export function validateToolCall(tools: Tool[], toolCall: ToolCall): unknown {
const tool = tools.find((t) => t.name === toolCall.name);
if (!tool) {
throw new Error(`Tool "${toolCall.name}" not found`);
}
return validateToolArguments(tool, toolCall);
}
export function validateToolArguments(tool: Tool, toolCall: ToolCall): unknown {
const args = structuredClone(toolCall.arguments);
Value.Convert(tool.parameters, args);
const validator = getValidator(tool.parameters);
if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) {
const coerced = coerceWithJsonSchema(args, tool.parameters);
if (coerced !== args) {
if (isRecord(args) && isRecord(coerced)) {
for (const key of Object.keys(args)) {
delete args[key];
}
Object.assign(args, coerced);
} else {
return validator.Check(coerced) ? coerced : args;
}
}
}
if (validator.Check(args)) {
return args;
}
const errors =
validator
.Errors(args)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown validation error";
throw new Error(
`Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`,
);
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

View File

@@ -0,0 +1,31 @@
{
"name": "@openclaw/llm-runtime",
"version": "0.0.0-private",
"private": true,
"files": [
"dist"
],
"type": "module",
"main": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./api-registry": {
"types": "./dist/api-registry.d.mts",
"import": "./dist/api-registry.mjs",
"default": "./dist/api-registry.mjs"
},
"./stream": {
"types": "./dist/stream.d.mts",
"import": "./dist/stream.mjs",
"default": "./dist/stream.mjs"
}
},
"dependencies": {
"@openclaw/llm-core": "workspace:*"
}
}

View File

@@ -0,0 +1,41 @@
import { createAssistantMessageEventStream, type Model } from "@openclaw/llm-core";
import { afterEach, describe, expect, it } from "vitest";
import { getApiProvider, registerApiProvider, unregisterApiProviders } from "./api-registry.js";
const TEST_SOURCE_ID = "test:llm-runtime-api-registry";
const model = {
id: "test-model",
name: "Test Model",
api: "test-api",
provider: "test-provider",
baseUrl: "https://example.invalid",
input: ["text"],
reasoning: false,
contextWindow: 1000,
maxTokens: 100,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
} satisfies Model;
describe("LLM API registry", () => {
afterEach(() => {
unregisterApiProviders(TEST_SOURCE_ID);
});
it("rejects mismatched model API calls", () => {
registerApiProvider(
{
api: "test-api",
stream: () => createAssistantMessageEventStream(),
streamSimple: () => createAssistantMessageEventStream(),
},
TEST_SOURCE_ID,
);
const provider = getApiProvider("test-api");
expect(provider).toBeDefined();
expect(() => provider?.streamSimple({ ...model, api: "other-api" }, { messages: [] })).toThrow(
"Mismatched api: other-api expected test-api",
);
});
});

View File

@@ -0,0 +1,104 @@
import type {
Api,
AssistantMessageEventStreamContract,
Context,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "../../llm-core/src/index.js";
// Type-only source import keeps plugin SDK declarations self-contained; package
// runtime emits no llm-core import from this module.
export type ApiStreamFunction = (
model: Model,
context: Context,
options?: StreamOptions,
) => AssistantMessageEventStreamContract;
export type ApiStreamSimpleFunction = (
model: Model,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStreamContract;
export interface ApiProvider<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
> {
api: TApi;
stream: StreamFunction<TApi, TOptions>;
streamSimple: StreamFunction<TApi, SimpleStreamOptions>;
}
interface ApiProviderInternal {
api: Api;
stream: ApiStreamFunction;
streamSimple: ApiStreamSimpleFunction;
}
type RegisteredApiProvider = {
provider: ApiProviderInternal;
sourceId?: string;
};
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
stream: StreamFunction<TApi, TOptions>,
): ApiStreamFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return stream(model as Model<TApi>, context, options as TOptions);
};
}
function wrapStreamSimple<TApi extends Api>(
api: TApi,
streamSimple: StreamFunction<TApi, SimpleStreamOptions>,
): ApiStreamSimpleFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return streamSimple(model as Model<TApi>, context, options);
};
}
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
sourceId?: string,
): void {
apiProviderRegistry.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
},
sourceId,
});
}
export function getApiProvider(api: Api): ApiProviderInternal | undefined {
return apiProviderRegistry.get(api)?.provider;
}
export function getApiProviders(): ApiProviderInternal[] {
return Array.from(apiProviderRegistry.values(), (entry) => entry.provider);
}
export function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of apiProviderRegistry.entries()) {
if (entry.sourceId === sourceId) {
apiProviderRegistry.delete(api);
}
}
}
export function clearApiProviders(): void {
apiProviderRegistry.clear();
}

View File

@@ -0,0 +1,9 @@
export {
clearApiProviders,
getApiProvider,
getApiProviders,
registerApiProvider,
unregisterApiProviders,
type ApiProvider,
} from "./api-registry.js";
export { complete, completeSimple, stream, streamSimple } from "./stream.js";

View File

@@ -0,0 +1,57 @@
import type {
Api,
AssistantMessage,
AssistantMessageEventStreamContract,
Context,
Model,
ProviderStreamOptions,
SimpleStreamOptions,
StreamOptions,
} from "../../llm-core/src/index.js";
// Type-only source import keeps plugin SDK declarations self-contained; package
// runtime emits no llm-core import from this module.
import { getApiProvider } from "./api-registry.js";
function resolveApiProvider(api: Api) {
const provider = getApiProvider(api);
if (!provider) {
throw new Error(`No API provider registered for api: ${api}`);
}
return provider;
}
export function stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStreamContract {
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, options as StreamOptions);
}
export async function complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): Promise<AssistantMessage> {
const s = stream(model, context, options);
return s.result();
}
export function streamSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStreamContract {
const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, options);
}
export async function completeSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): Promise<AssistantMessage> {
const s = streamSimple(model, context, options);
return s.result();
}

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*"]
}

15
pnpm-lock.yaml generated
View File

@@ -1769,6 +1769,9 @@ importers:
packages/agent-core:
dependencies:
'@openclaw/llm-core':
specifier: workspace:*
version: link:../llm-core
ignore:
specifier: 7.0.5
version: 7.0.5
@@ -1797,6 +1800,18 @@ importers:
specifier: 1.1.38
version: 1.1.38
packages/llm-core:
dependencies:
typebox:
specifier: 1.1.38
version: 1.1.38
packages/llm-runtime:
dependencies:
'@openclaw/llm-core':
specifier: workspace:*
version: link:../llm-core
packages/memory-host-sdk: {}
packages/net-policy:

View File

@@ -46,10 +46,12 @@ export const BUILD_ALL_STEPS = [
"pnpm-lock.yaml",
"npm-shrinkwrap.json",
"packages/plugin-sdk/package.json",
"packages/llm-core/package.json",
"packages/memory-host-sdk/package.json",
"tsconfig.json",
"tsconfig.plugin-sdk.dts.json",
"src/plugin-sdk",
"packages/llm-core/src",
"packages/memory-host-sdk/src",
"src/types",
"src/video-generation/dashscope-compatible.ts",

View File

@@ -49,6 +49,16 @@ export const EXTENSION_PACKAGE_BOUNDARY_BASE_PATHS = {
"@openclaw/discord/api.js": ["../dist/plugin-sdk/extensions/discord/api.d.ts"],
"@openclaw/slack/api.js": ["../dist/plugin-sdk/extensions/slack/api.d.ts"],
"@openclaw/whatsapp/api.js": ["../dist/plugin-sdk/extensions/whatsapp/api.d.ts"],
"@openclaw/llm-core": ["../dist/plugin-sdk/packages/llm-core/src/index.d.ts"],
"@openclaw/llm-core/diagnostics": [
"../dist/plugin-sdk/packages/llm-core/src/utils/diagnostics.d.ts",
],
"@openclaw/llm-core/event-stream": [
"../dist/plugin-sdk/packages/llm-core/src/utils/event-stream.d.ts",
],
"@openclaw/llm-core/types": ["../dist/plugin-sdk/packages/llm-core/src/types.d.ts"],
"@openclaw/llm-core/validation": ["../dist/plugin-sdk/packages/llm-core/src/validation.d.ts"],
"@openclaw/llm-core/*": ["../dist/plugin-sdk/packages/llm-core/src/*.d.ts"],
"@openclaw/*.js": ["../packages/plugin-sdk/dist/extensions/*.d.ts", "../extensions/*"],
"@openclaw/*": ["../packages/plugin-sdk/dist/extensions/*", "../extensions/*"],
"openclaw/plugin-sdk/qa-channel": ["../dist/plugin-sdk/src/plugin-sdk/qa-channel.d.ts"],

View File

@@ -15,6 +15,7 @@ const PLUGIN_SDK_TYPE_INPUTS = [
"tsconfig.json",
"src/plugin-sdk",
"src/auto-reply",
"packages/llm-core/src",
"packages/memory-host-sdk/src",
"src/video-generation/dashscope-compatible.ts",
"src/video-generation/types.ts",
@@ -26,6 +27,11 @@ const ROOT_DTS_REQUIRED_OUTPUTS = [
"dist/plugin-sdk/packages/memory-host-sdk/src/engine-embeddings.d.ts",
"dist/plugin-sdk/packages/memory-host-sdk/src/secret.d.ts",
"dist/plugin-sdk/packages/memory-host-sdk/src/status.d.ts",
"dist/plugin-sdk/packages/llm-core/src/index.d.ts",
"dist/plugin-sdk/packages/llm-core/src/types.d.ts",
"dist/plugin-sdk/packages/llm-core/src/utils/diagnostics.d.ts",
"dist/plugin-sdk/packages/llm-core/src/utils/event-stream.d.ts",
"dist/plugin-sdk/packages/llm-core/src/validation.d.ts",
"dist/plugin-sdk/error-runtime.d.ts",
"dist/plugin-sdk/plugin-entry.d.ts",
"dist/plugin-sdk/provider-auth.d.ts",

View File

@@ -1,4 +1,5 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { ExecAllowlistEntry } from "../infra/exec-approvals.types.js";
import { MAX_SAFE_TIMEOUT_DELAY_MS } from "../utils/timer-delay.js";
type StrictInlineEvalBoundary =
@@ -74,7 +75,7 @@ const requiresExecApprovalMock = vi.hoisted(() => vi.fn(() => true));
const hasDurableExecApprovalMock = vi.hoisted(() => vi.fn(() => false));
const resolveExecHostApprovalContextMock = vi.hoisted(() =>
vi.fn(() => ({
approvals: { allowlist: [], file: { version: 1, agents: {} } },
approvals: { allowlist: [] as ExecAllowlistEntry[], file: { version: 1, agents: {} } },
hostSecurity: "full",
hostAsk: "off",
askFallback: "deny",

View File

@@ -2,8 +2,8 @@ import {
Agent as CoreAgent,
type AgentOptions as CoreAgentOptions,
} from "../../../packages/agent-core/src/agent.js";
import type { CompleteSimpleFn, StreamFn } from "../../../packages/agent-core/src/llm.js";
import type { AgentCoreRuntimeDeps } from "../../../packages/agent-core/src/runtime-deps.js";
import type { CompleteSimpleFn, StreamFn } from "../../../packages/llm-core/src/index.js";
import { completeSimple, streamSimple } from "../../plugin-sdk/llm.js";
export const openClawAgentCoreRuntime = {

View File

@@ -1,4 +1,4 @@
import type { StreamFn as CoreStreamFn } from "../../../../packages/agent-core/src/llm.js";
import type { StreamFn as CoreStreamFn } from "../../../../packages/llm-core/src/index.js";
import type { Model } from "../../../llm/types.js";
import {
calculateContextTokens,

View File

@@ -2,7 +2,11 @@ import type { ThinkLevel } from "../auto-reply/thinking.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { formatErrorMessage } from "../infra/errors.js";
import { completeSimple } from "../llm/stream.js";
import type { Model, ThinkingLevel as SimpleCompletionThinkingLevel } from "../llm/types.js";
import type {
AssistantMessage,
Model,
ThinkingLevel as SimpleCompletionThinkingLevel,
} from "../llm/types.js";
import { prepareProviderRuntimeAuth } from "../plugins/provider-runtime.runtime.js";
import { resolveAgentDir, resolveAgentEffectiveModelPrimary } from "./agent-scope.js";
import { DEFAULT_PROVIDER } from "./defaults.js";
@@ -330,7 +334,7 @@ export async function completeWithPreparedSimpleCompletionModel(params: {
context: Parameters<typeof completeSimple>[1];
cfg?: OpenClawConfig;
options?: SimpleCompletionModelOptions;
}) {
}): Promise<AssistantMessage> {
const completionModel = prepareModelForSimpleCompletion({ model: params.model, cfg: params.cfg });
const { reasoning: rawReasoning, ...options } = params.options ?? {};
const reasoning = normalizeSimpleCompletionReasoning(rawReasoning);

View File

@@ -1,101 +1 @@
import type {
Api,
AssistantMessageEventStreamContract,
Context,
Model,
SimpleStreamOptions,
StreamFunction,
StreamOptions,
} from "./types.js";
export type ApiStreamFunction = (
model: Model,
context: Context,
options?: StreamOptions,
) => AssistantMessageEventStreamContract;
export type ApiStreamSimpleFunction = (
model: Model,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStreamContract;
export interface ApiProvider<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
> {
api: TApi;
stream: StreamFunction<TApi, TOptions>;
streamSimple: StreamFunction<TApi, SimpleStreamOptions>;
}
interface ApiProviderInternal {
api: Api;
stream: ApiStreamFunction;
streamSimple: ApiStreamSimpleFunction;
}
type RegisteredApiProvider = {
provider: ApiProviderInternal;
sourceId?: string;
};
const apiProviderRegistry = new Map<string, RegisteredApiProvider>();
function wrapStream<TApi extends Api, TOptions extends StreamOptions>(
api: TApi,
stream: StreamFunction<TApi, TOptions>,
): ApiStreamFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return stream(model as Model<TApi>, context, options as TOptions);
};
}
function wrapStreamSimple<TApi extends Api>(
api: TApi,
streamSimple: StreamFunction<TApi, SimpleStreamOptions>,
): ApiStreamSimpleFunction {
return (model, context, options) => {
if (model.api !== api) {
throw new Error(`Mismatched api: ${model.api} expected ${api}`);
}
return streamSimple(model as Model<TApi>, context, options);
};
}
export function registerApiProvider<TApi extends Api, TOptions extends StreamOptions>(
provider: ApiProvider<TApi, TOptions>,
sourceId?: string,
): void {
apiProviderRegistry.set(provider.api, {
provider: {
api: provider.api,
stream: wrapStream(provider.api, provider.stream),
streamSimple: wrapStreamSimple(provider.api, provider.streamSimple),
},
sourceId,
});
}
export function getApiProvider(api: Api): ApiProviderInternal | undefined {
return apiProviderRegistry.get(api)?.provider;
}
export function getApiProviders(): ApiProviderInternal[] {
return Array.from(apiProviderRegistry.values(), (entry) => entry.provider);
}
export function unregisterApiProviders(sourceId: string): void {
for (const [api, entry] of apiProviderRegistry.entries()) {
if (entry.sourceId === sourceId) {
apiProviderRegistry.delete(api);
}
}
}
export function clearApiProviders(): void {
apiProviderRegistry.clear();
}
export * from "../../packages/llm-runtime/src/api-registry.js";

View File

@@ -404,5 +404,3 @@ export function resetApiProviders(): void {
unregisterApiProviders(BUILT_IN_API_PROVIDER_SOURCE_ID);
registerBuiltInApiProviders();
}
registerBuiltInApiProviders();

View File

@@ -1,58 +1,11 @@
import "./providers/register-builtins.js";
import { getApiProvider } from "./api-registry.js";
import type {
Api,
AssistantMessage,
AssistantMessageEventStreamContract,
Context,
Model,
ProviderStreamOptions,
SimpleStreamOptions,
StreamOptions,
} from "./types.js";
import { registerBuiltInApiProviders } from "./providers/register-builtins.js";
registerBuiltInApiProviders();
export {
complete,
completeSimple,
stream,
streamSimple,
} from "../../packages/llm-runtime/src/stream.js";
export { getEnvApiKey } from "./env-api-keys.js";
function resolveApiProvider(api: Api) {
const provider = getApiProvider(api);
if (!provider) {
throw new Error(`No API provider registered for api: ${api}`);
}
return provider;
}
export function stream<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): AssistantMessageEventStreamContract {
const provider = resolveApiProvider(model.api);
return provider.stream(model, context, options as StreamOptions);
}
export async function complete<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: ProviderStreamOptions,
): Promise<AssistantMessage> {
const s = stream(model, context, options);
return s.result();
}
export function streamSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): AssistantMessageEventStreamContract {
const provider = resolveApiProvider(model.api);
return provider.streamSimple(model, context, options);
}
export async function completeSimple<TApi extends Api>(
model: Model<TApi>,
context: Context,
options?: SimpleStreamOptions,
): Promise<AssistantMessage> {
const s = streamSimple(model, context, options);
return s.result();
}

View File

@@ -1,562 +1 @@
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.js";
export type KnownApi =
| "openai-completions"
| "mistral-conversations"
| "openai-responses"
| "azure-openai-responses"
| "openai-codex-responses"
| "anthropic-messages"
| "bedrock-converse-stream"
| "google-generative-ai"
| "google-vertex";
export type Api = KnownApi | (string & {});
export type KnownImagesApi = "openrouter-images";
export type ImagesApi = KnownImagesApi | (string & {});
export type Provider = string;
export type KnownImagesProvider = "openrouter";
export type ImagesProvider = string;
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
export type ModelThinkingLevel = "off" | ThinkingLevel;
export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>;
/** Token budgets for each thinking level (token-based providers only) */
export interface ThinkingBudgets {
minimal?: number;
low?: number;
medium?: number;
high?: number;
max?: number;
}
// Base options all providers share
export type CacheRetention = "none" | "short" | "long";
export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
export type MaybePromise<T> = T | Promise<T>;
export interface ProviderResponse {
status: number;
headers: Record<string, string>;
}
export interface StreamOptions {
temperature?: number;
maxTokens?: number;
signal?: AbortSignal;
apiKey?: string;
/**
* Preferred transport for providers that support multiple transports.
* Providers that do not support this option ignore it.
*/
transport?: Transport;
/**
* Prompt cache retention preference. Providers map this to their supported values.
* Default: "short".
*/
cacheRetention?: CacheRetention;
/**
* Optional session identifier for providers that support session-based caching.
* Providers can use this to enable prompt caching, request routing, or other
* session-aware features. Ignored by providers that don't support it.
*/
sessionId?: string;
/**
* Optional provider prompt-cache affinity key, distinct from transcript/session identity.
* Providers that do not support separate cache affinity ignore it.
*/
promptCacheKey?: string;
/**
* Optional callback for inspecting or replacing provider payloads before sending.
* Return undefined to keep the payload unchanged.
*/
onPayload?: (payload: unknown, model: Model) => MaybePromise<unknown>;
/**
* Optional callback invoked after an HTTP response is received and before
* its body stream is consumed.
*/
onResponse?: (response: ProviderResponse, model: Model) => void | Promise<void>;
/**
* Optional custom HTTP headers to include in API requests.
* Merged with provider defaults; can override default headers.
* Not supported by all providers (e.g., AWS Bedrock uses SDK auth).
*/
headers?: Record<string, string>;
/**
* HTTP request timeout in milliseconds for providers/SDKs that support it.
* For example, OpenAI and Anthropic SDK clients default to 10 minutes.
*/
timeoutMs?: number;
/**
* Maximum retry attempts for providers/SDKs that support client-side retries.
* For example, OpenAI and Anthropic SDK clients default to 2.
*/
maxRetries?: number;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately
* with an error containing the requested delay, allowing higher-level retry logic
* to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Optional metadata to include in API requests.
* Providers extract the fields they understand and ignore the rest.
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
*/
metadata?: Record<string, unknown>;
}
export type ProviderStreamOptions = StreamOptions & Record<string, unknown>;
export interface ImagesOptions {
signal?: AbortSignal;
apiKey?: string;
/**
* Optional callback for inspecting or replacing provider payloads before sending.
* Return undefined to keep the payload unchanged.
*/
onPayload?: (payload: unknown, model: ImagesModel) => MaybePromise<unknown>;
/**
* Optional callback invoked after an HTTP response is received.
*/
onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise<void>;
/**
* Optional custom HTTP headers to include in API requests.
* Merged with provider defaults; can override default headers.
*/
headers?: Record<string, string>;
/**
* HTTP request timeout in milliseconds for providers/SDKs that support it.
*/
timeoutMs?: number;
/**
* Maximum retry attempts for providers/SDKs that support client-side retries.
*/
maxRetries?: number;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately
* with an error containing the requested delay, allowing higher-level retry logic
* to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Optional metadata to include in API requests.
* Providers extract the fields they understand and ignore the rest.
*/
metadata?: Record<string, unknown>;
}
export type ProviderImagesOptions = ImagesOptions & Record<string, unknown>;
// Unified options with reasoning passed to streamSimple() and completeSimple()
export interface SimpleStreamOptions extends StreamOptions {
reasoning?: ThinkingLevel;
/** Custom token budgets for thinking levels (token-based providers only) */
thinkingBudgets?: ThinkingBudgets;
}
// Generic StreamFunction with typed options.
//
// Contract:
// - Must return an AssistantMessageEventStream.
// - Once invoked, request/model/runtime failures should be encoded in the
// returned stream, not thrown.
// - Error termination must produce an AssistantMessage with stopReason
// "error" or "aborted" and errorMessage, emitted via the stream protocol.
export type StreamFunction<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
> = (
model: Model<TApi>,
context: Context,
options?: TOptions,
) => AssistantMessageEventStreamContract;
export type ImagesFunction<
TApi extends ImagesApi = ImagesApi,
TOptions extends ImagesOptions = ImagesOptions,
> = (
model: ImagesModel<TApi>,
context: ImagesContext,
options?: TOptions,
) => Promise<AssistantImages>;
export interface TextSignatureV1 {
v: 1;
id: string;
phase?: "commentary" | "final_answer";
}
export interface TextContent {
type: "text";
text: string;
textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)
}
export interface ThinkingContent {
type: "thinking";
thinking: string;
thinkingSignature?: string; // e.g., for OpenAI responses, the reasoning item ID
/** When true, the thinking content was redacted by safety filters. The opaque
* encrypted payload is stored in `thinkingSignature` so it can be passed back
* to the API for multi-turn continuity. */
redacted?: boolean;
}
export interface ImageContent {
type: "image";
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/jpeg", "image/png"
}
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, unknown>;
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
}
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number; // Unix timestamp in milliseconds
}
export interface AssistantMessage {
role: "assistant";
content: (TextContent | ThinkingContent | ToolCall)[];
api: Api;
provider: Provider;
model: string;
responseModel?: string; // Concrete `chunk.model` when different from the requested `model` (e.g. OpenRouter `auto` -> `anthropic/...`)
responseId?: string; // Provider-specific response/message identifier when the upstream API exposes one
diagnostics?: AssistantMessageDiagnostic[]; // Redacted provider/runtime diagnostics for failures and recoveries.
usage: Usage;
stopReason: StopReason;
errorMessage?: string;
timestamp: number; // Unix timestamp in milliseconds
}
export interface ToolResultMessage<TDetails = unknown> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[]; // Supports text and images
details?: TDetails;
isError: boolean;
timestamp: number; // Unix timestamp in milliseconds
}
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
export type ImagesInputContent = TextContent | ImageContent;
export type ImagesOutputContent = TextContent | ImageContent;
export interface ImagesContext {
input: ImagesInputContent[];
}
export type ImagesStopReason = "stop" | "error" | "aborted";
export interface AssistantImages {
api: ImagesApi;
provider: ImagesProvider;
model: string;
output: ImagesOutputContent[];
responseId?: string;
usage?: Usage;
stopReason: ImagesStopReason;
errorMessage?: string;
timestamp: number; // Unix timestamp in milliseconds
}
import type { TSchema } from "typebox";
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
export interface Context {
systemPrompt?: string;
messages: Message[];
tools?: Tool[];
}
/**
* Event protocol for AssistantMessageEventStream.
*
* Streams should emit `start` before partial updates, then terminate with either:
* - `done` carrying the final successful AssistantMessage, or
* - `error` carrying the final AssistantMessage with stopReason "error" or "aborted"
* and errorMessage.
*/
export type AssistantMessageEvent =
| { type: "start"; partial: AssistantMessage }
| { type: "text_start"; contentIndex: number; partial: AssistantMessage }
| { type: "text_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| {
type: "done";
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
message: AssistantMessage;
}
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
export interface AssistantMessageEventStreamContract extends AsyncIterable<AssistantMessageEvent> {
push(event: AssistantMessageEvent): void;
end(result?: AssistantMessage): void;
result(): Promise<AssistantMessage>;
}
/**
* Compatibility settings for OpenAI-compatible completions APIs.
* Use this to override URL-based auto-detection for custom providers.
*/
export interface OpenAICompletionsCompat {
/** Whether the provider supports the `store` field. Default: auto-detected from URL. */
supportsStore?: boolean;
/** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */
supportsDeveloperRole?: boolean;
/** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */
supportsReasoningEffort?: boolean;
/** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */
supportsUsageInStreaming?: boolean;
/** Which field to use for max tokens. Default: auto-detected from URL. */
maxTokensField?: "max_completion_tokens" | "max_tokens";
/** Whether tool results require the `name` field. Default: auto-detected from URL. */
requiresToolResultName?: boolean;
/** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */
requiresAssistantAfterToolResult?: boolean;
/** Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. */
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
| "deepseek"
| "together"
| "zai"
| "qwen"
| "qwen-chat-template";
/** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
vercelGatewayRouting?: VercelGatewayRouting;
/** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */
zaiToolStream?: boolean;
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
supportsStrictMode?: boolean;
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */
cacheControlFormat?: "anthropic";
/** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */
sendSessionAffinityHeaders?: boolean;
/** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */
supportsLongCacheRetention?: boolean;
}
/** Compatibility settings for OpenAI Responses APIs. */
export interface OpenAIResponsesCompat {
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
sendSessionIdHeader?: boolean;
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
supportsLongCacheRetention?: boolean;
}
/** Compatibility settings for Anthropic Messages-compatible APIs. */
export interface AnthropicMessagesCompat {
/**
* Whether the provider accepts per-tool `eager_input_streaming`.
* When false, the Anthropic provider omits `tools[].eager_input_streaming`
* and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header
* for tool-enabled requests.
* Default: true.
*/
supportsEagerToolInputStreaming?: boolean;
/** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */
supportsLongCacheRetention?: boolean;
/**
* Whether to send the `x-session-affinity` header from `options.sessionId`
* when caching is enabled. Required for providers like Fireworks that use
* session affinity for prompt cache routing (requests to the same replica
* maximize cache hits).
* Default: false.
*/
sendSessionAffinityHeaders?: boolean;
/**
* Whether the provider supports Anthropic-style `cache_control` markers on
* tool definitions. When false, `cache_control` is omitted from tool params.
* Some Anthropic-compatible providers (e.g., Fireworks) do not support this
* field on tools and may reject or ignore it.
* Default: true.
*/
supportsCacheControlOnTools?: boolean;
}
/**
* OpenRouter provider routing preferences.
* Controls which upstream providers OpenRouter routes requests to.
* Sent as the `provider` field in the OpenRouter API request body.
* @see https://openrouter.ai/docs/guides/routing/provider-selection
*/
export interface OpenRouterRouting {
/** Whether to allow backup providers to serve requests. Default: true. */
allow_fallbacks?: boolean;
/** Whether to filter providers to only those that support all parameters in the request. Default: false. */
require_parameters?: boolean;
/** Data collection setting. "allow" (default): allow providers that may store/train on data. "deny": only use providers that don't collect user data. */
data_collection?: "deny" | "allow";
/** Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. */
zdr?: boolean;
/** Whether to restrict routing to only models that allow text distillation. */
enforce_distillable_text?: boolean;
/** An ordered list of provider names/slugs to try in sequence, falling back to the next if unavailable. */
order?: string[];
/** List of provider names/slugs to exclusively allow for this request. */
only?: string[];
/** List of provider names/slugs to skip for this request. */
ignore?: string[];
/** A list of quantization levels to filter providers by (e.g., ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"]). */
quantizations?: string[];
/** Sorting strategy. Can be a string (e.g., "price", "throughput", "latency") or an object with `by` and `partition`. */
sort?:
| string
| {
/** The sorting metric: "price", "throughput", "latency". */
by?: string;
/** Partitioning strategy: "model" (default) or "none". */
partition?: string | null;
};
/** Maximum price per million tokens (USD). */
max_price?: {
/** Price per million prompt tokens. */
prompt?: number | string;
/** Price per million completion tokens. */
completion?: number | string;
/** Price per image. */
image?: number | string;
/** Price per audio unit. */
audio?: number | string;
/** Price per request. */
request?: number | string;
};
/** Preferred minimum throughput (tokens/second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */
preferred_min_throughput?:
| number
| {
/** Minimum tokens/second at the 50th percentile. */
p50?: number;
/** Minimum tokens/second at the 75th percentile. */
p75?: number;
/** Minimum tokens/second at the 90th percentile. */
p90?: number;
/** Minimum tokens/second at the 99th percentile. */
p99?: number;
};
/** Preferred maximum latency (seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */
preferred_max_latency?:
| number
| {
/** Maximum latency in seconds at the 50th percentile. */
p50?: number;
/** Maximum latency in seconds at the 75th percentile. */
p75?: number;
/** Maximum latency in seconds at the 90th percentile. */
p90?: number;
/** Maximum latency in seconds at the 99th percentile. */
p99?: number;
};
}
/**
* Vercel AI Gateway routing preferences.
* Controls which upstream providers the gateway routes requests to.
* @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options
*/
export interface VercelGatewayRouting {
/** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */
only?: string[];
/** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */
order?: string[];
}
// Model interface for the unified model system
export interface Model<TApi extends Api = Api> {
id: string;
name: string;
api: TApi;
provider: Provider;
baseUrl: string;
reasoning: boolean;
/**
* Maps OpenClaw thinking levels to provider/model-specific values.
* Missing keys use provider defaults. null marks a level as unsupported.
*/
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
cost: {
input: number; // $/million tokens
output: number; // $/million tokens
cacheRead: number; // $/million tokens
cacheWrite: number; // $/million tokens
};
contextWindow: number;
maxTokens: number;
headers?: Record<string, string>;
/** Compatibility overrides for OpenAI-compatible APIs. If not set, auto-detected from baseUrl. */
compat?: TApi extends "openai-completions"
? OpenAICompletionsCompat
: TApi extends "openai-responses"
? OpenAIResponsesCompat
: TApi extends "anthropic-messages"
? AnthropicMessagesCompat
: never;
}
export interface ImagesModel<TApi extends ImagesApi = ImagesApi> extends Omit<
Model,
"api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"
> {
api: TApi;
provider: ImagesProvider;
output: ("text" | "image")[];
}
export * from "../../packages/llm-core/src/types.js";

View File

@@ -1,51 +1 @@
export interface DiagnosticErrorInfo {
name?: string;
message: string;
stack?: string;
code?: string | number;
}
export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: DiagnosticErrorInfo;
details?: Record<string, unknown>;
}
export function formatThrownValue(value: unknown): string {
if (value instanceof Error) {
return value.message || value.name;
}
if (typeof value === "string") {
return value;
}
return String(value);
}
export function extractDiagnosticError(error: unknown): DiagnosticErrorInfo {
if (!(error instanceof Error)) {
return { name: "ThrownValue", message: formatThrownValue(error) };
}
const code = (error as Error & { code?: unknown }).code;
return {
name: error.name || undefined,
message: error.message || error.name,
stack: error.stack,
code: typeof code === "string" || typeof code === "number" ? code : undefined,
};
}
export function createAssistantMessageDiagnostic(
type: string,
error: unknown,
details?: Record<string, unknown>,
): AssistantMessageDiagnostic {
return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details };
}
export function appendAssistantMessageDiagnostic(
message: { diagnostics?: AssistantMessageDiagnostic[] },
diagnostic: AssistantMessageDiagnostic,
): void {
message.diagnostics = [...(message.diagnostics ?? []), diagnostic];
}
export * from "../../../packages/llm-core/src/utils/diagnostics.js";

View File

@@ -1,97 +1 @@
import type { AssistantMessage, AssistantMessageEvent } from "../types.js";
// Generic event stream class for async iteration
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
this.isComplete = isComplete;
this.extractResult = extractResult;
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
}
push(event: T): void {
if (this.done) {
return;
}
if (this.isComplete(event)) {
this.done = true;
this.resolveFinalResult(this.extractResult(event));
}
// Deliver to waiting consumer or queue it
const waiter = this.waiting.shift();
if (waiter) {
waiter({ value: event, done: false });
} else {
this.queue.push(event);
}
}
end(result?: R): void {
this.done = true;
if (result !== undefined) {
this.resolveFinalResult(result);
}
// Notify all waiting consumers that we're done
while (this.waiting.length > 0) {
const waiter = this.waiting.shift()!;
waiter({ value: undefined as unknown, done: true });
}
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
} else if (this.done) {
return;
} else {
const result = await new Promise<IteratorResult<T>>((resolve) =>
this.waiting.push(resolve),
);
if (result.done) {
return;
}
yield result.value;
}
}
}
result(): Promise<R> {
return this.finalResultPromise;
}
}
export class AssistantMessageEventStream extends EventStream<
AssistantMessageEvent,
AssistantMessage
> {
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") {
return event.message;
} else if (event.type === "error") {
return event.error;
}
throw new Error("Unexpected event type for final result");
},
);
}
}
/** Factory function for AssistantMessageEventStream (for use in extensions) */
export function createAssistantMessageEventStream(): AssistantMessageEventStream {
return new AssistantMessageEventStream();
}
export * from "../../../packages/llm-core/src/utils/event-stream.js";

View File

@@ -2,8 +2,8 @@ import {
Agent as CoreAgent,
type AgentOptions as CoreAgentOptions,
} from "../../packages/agent-core/src/agent.js";
import type { CompleteSimpleFn, StreamFn } from "../../packages/agent-core/src/llm.js";
import type { AgentCoreRuntimeDeps } from "../../packages/agent-core/src/runtime-deps.js";
import type { CompleteSimpleFn, StreamFn } from "../../packages/llm-core/src/index.js";
import { completeSimple, streamSimple } from "./llm.js";
export const openClawAgentCoreRuntime = {

View File

@@ -44,11 +44,8 @@ export type {
export {
AssistantMessageEventStream,
createAssistantMessageEventStream,
} from "../llm/utils/event-stream.js";
} from "../../packages/llm-core/src/utils/event-stream.js";
export { parseStreamingJson } from "../llm/utils/json-parse.js";
export { createHttpProxyAgentsForTarget } from "../llm/utils/node-http-proxy.js";
export { sanitizeSurrogates } from "../llm/utils/sanitize-unicode.js";
export {
validateToolArguments,
validateToolCall,
} from "../../packages/agent-core/src/validation.js";
export { validateToolArguments, validateToolCall } from "../../packages/llm-core/src/validation.js";

View File

@@ -224,6 +224,7 @@ function runWrapper(
PATH: [...(options.extraPathEntries ?? []), binDir, gitBinDir, process.env.PATH ?? ""]
.filter(Boolean)
.join(path.delimiter),
CRABBOX_PROVIDER: "",
OPENCLAW_CRABBOX_WRAPPER_IGNORE_REPO_BINARY: "1",
...(options.configJson
? { OPENCLAW_FAKE_CRABBOX_CONFIG_JSON: JSON.stringify(options.configJson) }
@@ -415,6 +416,7 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
const result = runWrapper(
"provider: hetzner, aws, local-container, blacksmith-testbox, or cloudflare\n",
["run", "--target", "windows", "--", "echo ok"],
{ env: { CRABBOX_PROVIDER: "aws" } },
);
expect(result.status).toBe(0);
@@ -429,15 +431,11 @@ describe.concurrent("scripts/crabbox-wrapper", () => {
});
it("keeps existing Windows lease selections on the configured provider", () => {
const result = runWrapper(azureProviderHelp, [
"run",
"--id",
"cbx_existing",
"--target",
"windows",
"--",
"echo ok",
]);
const result = runWrapper(
azureProviderHelp,
["run", "--id", "cbx_existing", "--target", "windows", "--", "echo ok"],
{ env: { CRABBOX_PROVIDER: "aws" } },
);
expect(result.status).toBe(0);
expect(parseFakeCrabboxOutput(result).args).toEqual([

View File

@@ -221,6 +221,22 @@ export const sharedVitestConfig = {
find: "@openclaw/gateway-protocol",
replacement: path.join(repoRoot, "packages", "gateway-protocol", "src", "index.ts"),
},
{
find: "@openclaw/llm-core/diagnostics",
replacement: path.join(repoRoot, "packages", "llm-core", "src", "utils", "diagnostics.ts"),
},
{
find: "@openclaw/llm-core/event-stream",
replacement: path.join(repoRoot, "packages", "llm-core", "src", "utils", "event-stream.ts"),
},
{
find: "@openclaw/llm-core/validation",
replacement: path.join(repoRoot, "packages", "llm-core", "src", "validation.ts"),
},
{
find: "@openclaw/llm-core",
replacement: path.join(repoRoot, "packages", "llm-core", "src", "index.ts"),
},
{
find: "@openclaw/net-policy/ip",
replacement: path.join(repoRoot, "packages", "net-policy", "src", "ip.ts"),

View File

@@ -29,6 +29,13 @@
"openclaw/plugin-sdk/*": ["./src/plugin-sdk/*.ts"],
"@openclaw/agent-core": ["./packages/agent-core/src/index.ts"],
"@openclaw/agent-core/*": ["./packages/agent-core/src/*"],
"@openclaw/llm-core": ["./packages/llm-core/src/index.ts"],
"@openclaw/llm-core/diagnostics": ["./packages/llm-core/src/utils/diagnostics.ts"],
"@openclaw/llm-core/event-stream": ["./packages/llm-core/src/utils/event-stream.ts"],
"@openclaw/llm-core/validation": ["./packages/llm-core/src/validation.ts"],
"@openclaw/llm-core/*": ["./packages/llm-core/src/*"],
"@openclaw/llm-runtime": ["./packages/llm-runtime/src/index.ts"],
"@openclaw/llm-runtime/*": ["./packages/llm-runtime/src/*"],
"@openclaw/gateway-client": ["./packages/gateway-client/src/index.ts"],
"@openclaw/gateway-client/*": ["./packages/gateway-client/src/*"],
"@openclaw/gateway-protocol": ["./packages/gateway-protocol/src/index.ts"],

View File

@@ -13,6 +13,7 @@
},
"include": [
"src/plugin-sdk/**/*.ts",
"packages/llm-core/src/**/*.ts",
"packages/memory-host-sdk/src/**/*.ts",
"src/video-generation/dashscope-compatible.ts",
"src/video-generation/types.ts",

View File

@@ -393,8 +393,28 @@ function buildSpeechCoreDistEntries(): Record<string, string> {
};
}
function buildLlmCoreDistEntries(): Record<string, string> {
return {
index: "packages/llm-core/src/index.ts",
types: "packages/llm-core/src/types.ts",
"utils/diagnostics": "packages/llm-core/src/utils/diagnostics.ts",
"utils/event-stream": "packages/llm-core/src/utils/event-stream.ts",
validation: "packages/llm-core/src/validation.ts",
};
}
function buildLlmRuntimeDistEntries(): Record<string, string> {
return {
index: "packages/llm-runtime/src/index.ts",
"api-registry": "packages/llm-runtime/src/api-registry.ts",
stream: "packages/llm-runtime/src/stream.ts",
};
}
function shouldExternalizeAgentCoreDependency(id: string): boolean {
return (
id === "@openclaw/llm-core" ||
id.startsWith("@openclaw/llm-core/") ||
id === "ignore" ||
id === "openclaw" ||
id.startsWith("openclaw/") ||
@@ -426,6 +446,14 @@ function shouldExternalizeSpeechCoreDependency(id: string): boolean {
return id === "openclaw" || id.startsWith("openclaw/");
}
function shouldExternalizeLlmCoreDependency(id: string): boolean {
return id === "typebox" || id.startsWith("typebox/");
}
function shouldExternalizeLlmRuntimeDependency(id: string): boolean {
return id === "@openclaw/llm-core" || id.startsWith("@openclaw/llm-core/");
}
const coreDistEntries = buildCoreDistEntries();
const dockerE2eHarnessEntries = buildDockerE2eHarnessEntries();
const rootBundledPluginBuildEntries = bundledPluginBuildEntries.filter(
@@ -504,6 +532,24 @@ export default defineConfig([
neverBundle: shouldExternalizeSpeechCoreDependency,
},
}),
nodeWorkspacePackageBuildConfig({
clean: true,
dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,
entry: buildLlmCoreDistEntries(),
outDir: "packages/llm-core/dist",
deps: {
neverBundle: shouldExternalizeLlmCoreDependency,
},
}),
nodeWorkspacePackageBuildConfig({
clean: true,
dts: RUN_NODE_SKIP_DTS_BUILD ? false : undefined,
entry: buildLlmRuntimeDistEntries(),
outDir: "packages/llm-runtime/dist",
deps: {
neverBundle: shouldExternalizeLlmRuntimeDependency,
},
}),
nodeBuildConfig({
// Build core entrypoints, plugin-sdk subpaths, bundled plugin entrypoints,
// and bundled hooks in one graph so runtime singletons are emitted once.