From aa0d6e1bca55a662450ee69beefc0523d296349f Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 30 May 2026 07:45:04 +0200 Subject: [PATCH] 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 --- .github/workflows/ci.yml | 8 +- .../tsconfig.package-boundary.paths.json | 12 + extensions/xai/tsconfig.json | 18 + packages/agent-core/package.json | 1 + packages/agent-core/src/agent-loop.ts | 39 +- packages/agent-core/src/agent.ts | 4 +- .../agent-core/src/harness/agent-harness.ts | 7 +- .../compaction/branch-summarization.ts | 2 +- .../src/harness/compaction/compaction.ts | 2 +- .../src/harness/compaction/utils.ts | 2 +- packages/agent-core/src/harness/messages.ts | 2 +- .../agent-core/src/harness/session/session.ts | 2 +- packages/agent-core/src/harness/types.ts | 4 +- packages/agent-core/src/llm.ts | 269 +------- packages/agent-core/src/runtime-deps.ts | 2 +- packages/agent-core/src/types.ts | 2 +- packages/agent-core/src/validation.ts | 325 +--------- packages/llm-core/package.json | 41 ++ packages/llm-core/src/index.ts | 4 + packages/llm-core/src/types.ts | 582 ++++++++++++++++++ packages/llm-core/src/utils/diagnostics.ts | 51 ++ packages/llm-core/src/utils/event-stream.ts | 101 +++ .../src/validation.test.ts | 2 +- packages/llm-core/src/validation.ts | 324 ++++++++++ packages/llm-core/tsconfig.json | 8 + packages/llm-runtime/package.json | 31 + packages/llm-runtime/src/api-registry.test.ts | 41 ++ packages/llm-runtime/src/api-registry.ts | 104 ++++ packages/llm-runtime/src/index.ts | 9 + packages/llm-runtime/src/stream.ts | 57 ++ packages/llm-runtime/tsconfig.json | 8 + pnpm-lock.yaml | 15 + scripts/build-all.mjs | 2 + scripts/lib/extension-package-boundary.ts | 10 + ...e-extension-package-boundary-artifacts.mjs | 6 + src/agents/bash-tools.exec-host-node.test.ts | 3 +- src/agents/runtime/index.ts | 2 +- src/agents/sessions/compaction/compaction.ts | 2 +- src/agents/simple-completion-runtime.ts | 8 +- src/llm/api-registry.ts | 102 +-- src/llm/providers/register-builtins.ts | 2 - src/llm/stream.ts | 65 +- src/llm/types.ts | 563 +---------------- src/llm/utils/diagnostics.ts | 52 +- src/llm/utils/event-stream.ts | 98 +-- src/plugin-sdk/agent-core.ts | 2 +- src/plugin-sdk/llm.ts | 7 +- test/scripts/crabbox-wrapper.test.ts | 16 +- test/vitest/vitest.shared.config.ts | 16 + tsconfig.json | 7 + tsconfig.plugin-sdk.dts.json | 1 + tsdown.config.ts | 46 ++ 52 files changed, 1581 insertions(+), 1508 deletions(-) create mode 100644 packages/llm-core/package.json create mode 100644 packages/llm-core/src/index.ts create mode 100644 packages/llm-core/src/types.ts create mode 100644 packages/llm-core/src/utils/diagnostics.ts create mode 100644 packages/llm-core/src/utils/event-stream.ts rename packages/{agent-core => llm-core}/src/validation.test.ts (96%) create mode 100644 packages/llm-core/src/validation.ts create mode 100644 packages/llm-core/tsconfig.json create mode 100644 packages/llm-runtime/package.json create mode 100644 packages/llm-runtime/src/api-registry.test.ts create mode 100644 packages/llm-runtime/src/api-registry.ts create mode 100644 packages/llm-runtime/src/index.ts create mode 100644 packages/llm-runtime/src/stream.ts create mode 100644 packages/llm-runtime/tsconfig.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a93df4117b..8d56618055d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 \ diff --git a/extensions/tsconfig.package-boundary.paths.json b/extensions/tsconfig.package-boundary.paths.json index ea41a0d8112..edc5ec7dbbc 100644 --- a/extensions/tsconfig.package-boundary.paths.json +++ b/extensions/tsconfig.package-boundary.paths.json @@ -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"], diff --git a/extensions/xai/tsconfig.json b/extensions/xai/tsconfig.json index b4a92551965..9a551441649 100644 --- a/extensions/xai/tsconfig.json +++ b/extensions/xai/tsconfig.json @@ -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": [ diff --git a/packages/agent-core/package.json b/packages/agent-core/package.json index eca832dba89..5a50d13e226 100644 --- a/packages/agent-core/package.json +++ b/packages/agent-core/package.json @@ -115,6 +115,7 @@ } }, "dependencies": { + "@openclaw/llm-core": "workspace:*", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" diff --git a/packages/agent-core/src/agent-loop.ts b/packages/agent-core/src/agent-loop.ts index c4fe1803ba5..e39328ea0a8 100644 --- a/packages/agent-core/src/agent-loop.ts +++ b/packages/agent-core/src/agent-loop.ts @@ -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 { - return new EventStream( + return new EventStreamConstructor( (event: AgentEvent) => event.type === "agent_end", (event: AgentEvent) => (event.type === "agent_end" ? event.messages : []), ); diff --git a/packages/agent-core/src/agent.ts b/packages/agent-core/src/agent.ts index 6463bbb2267..248f164e4f0 100644 --- a/packages/agent-core/src/agent.ts +++ b/packages/agent-core/src/agent.ts @@ -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, diff --git a/packages/agent-core/src/harness/agent-harness.ts b/packages/agent-core/src/harness/agent-harness.ts index 2177823b9d0..ef2f36e5444 100644 --- a/packages/agent-core/src/harness/agent-harness.ts +++ b/packages/agent-core/src/harness/agent-harness.ts @@ -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, diff --git a/packages/agent-core/src/harness/compaction/branch-summarization.ts b/packages/agent-core/src/harness/compaction/branch-summarization.ts index 34c3da14b5d..35cbf2e5915 100644 --- a/packages/agent-core/src/harness/compaction/branch-summarization.ts +++ b/packages/agent-core/src/harness/compaction/branch-summarization.ts @@ -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, diff --git a/packages/agent-core/src/harness/compaction/compaction.ts b/packages/agent-core/src/harness/compaction/compaction.ts index cda4dcfcc3e..9dfda002841 100644 --- a/packages/agent-core/src/harness/compaction/compaction.ts +++ b/packages/agent-core/src/harness/compaction/compaction.ts @@ -5,7 +5,7 @@ import type { SimpleStreamOptions, StreamFn, Usage, -} from "../../llm.js"; +} from "../../../../llm-core/src/index.js"; import { type AgentCoreCompletionRuntimeDeps, resolveAgentCoreCompleteFn, diff --git a/packages/agent-core/src/harness/compaction/utils.ts b/packages/agent-core/src/harness/compaction/utils.ts index 64e31a0dc74..56302df5c70 100644 --- a/packages/agent-core/src/harness/compaction/utils.ts +++ b/packages/agent-core/src/harness/compaction/utils.ts @@ -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. */ diff --git a/packages/agent-core/src/harness/messages.ts b/packages/agent-core/src/harness/messages.ts index bdc63b85a48..e610f3e551b 100644 --- a/packages/agent-core/src/harness/messages.ts +++ b/packages/agent-core/src/harness/messages.ts @@ -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"; diff --git a/packages/agent-core/src/harness/session/session.ts b/packages/agent-core/src/harness/session/session.ts index 6b7c2e505a2..801f28c6522 100644 --- a/packages/agent-core/src/harness/session/session.ts +++ b/packages/agent-core/src/harness/session/session.ts @@ -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, diff --git a/packages/agent-core/src/harness/types.ts b/packages/agent-core/src/harness/types.ts index b9593e0685e..3ec8038e21a 100644 --- a/packages/agent-core/src/harness/types.ts +++ b/packages/agent-core/src/harness/types.ts @@ -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"; diff --git a/packages/agent-core/src/llm.ts b/packages/agent-core/src/llm.ts index 5468844c058..90cedd02eff 100644 --- a/packages/agent-core/src/llm.ts +++ b/packages/agent-core/src/llm.ts @@ -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 | Promise; - -export interface ProviderResponse { - status: number; - headers: Record; -} - -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; -} - -export interface SimpleStreamOptions { - temperature?: number; - maxTokens?: number; - signal?: AbortSignal; - apiKey?: string; - transport?: Transport; - cacheRetention?: CacheRetention; - sessionId?: string; - onPayload?: (payload: unknown, model: Model) => MaybePromise; - onResponse?: (response: ProviderResponse, model: Model) => void | Promise; - headers?: Record; - timeoutMs?: number; - maxRetries?: number; - maxRetryDelayMs?: number; - metadata?: Record; - 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; - 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 { - id: string; - name: string; - api: TApi; - provider: string; - baseUrl: string; - input: ("text" | "image")[]; - reasoning: boolean; - thinkingLevelMap?: Partial>; - contextWindow: number; - maxTokens: number; - cost: { - input: number; - output: number; - cacheRead: number; - cacheWrite: number; - }; - headers?: Record; - // Provider-owned compatibility payload; core carries it without inspecting it. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - compat?: any; -} - -export interface Tool { - 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; - message: AssistantMessage; - } - | { type: "error"; reason: Extract; error: AssistantMessage }; - -export class EventStream implements AsyncIterable { - private queue: T[] = []; - private waiting: ((value: IteratorResult) => void)[] = []; - private done = false; - private finalResultPromise: Promise; - 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 { - while (true) { - if (this.queue.length > 0) { - yield this.queue.shift()!; - } else if (this.done) { - return; - } else { - const result = await new Promise>((resolve) => - this.waiting.push(resolve), - ); - if (result.done) { - return; - } - yield result.value; - } - } - } - - result(): Promise { - return this.finalResultPromise; - } -} - -export interface AssistantMessageEventStream extends AsyncIterable { - result(): Promise; -} - -export type StreamFn = ( - model: Model, - context: Context, - options?: SimpleStreamOptions, -) => AssistantMessageEventStream | Promise; - -export type CompleteSimpleFn = ( - model: Model, - context: Pick, - options?: SimpleStreamOptions, -) => Promise; - -export type ValidateToolArgumentsFn = (tool: Tool, toolCall: ToolCall) => unknown; +export * from "@openclaw/llm-core"; diff --git a/packages/agent-core/src/runtime-deps.ts b/packages/agent-core/src/runtime-deps.ts index ebfc8387d01..5a1cd1d8556 100644 --- a/packages/agent-core/src/runtime-deps.ts +++ b/packages/agent-core/src/runtime-deps.ts @@ -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; diff --git a/packages/agent-core/src/types.ts b/packages/agent-core/src/types.ts index 4cc7391c521..aae006171a6 100644 --- a/packages/agent-core/src/types.ts +++ b/packages/agent-core/src/types.ts @@ -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. diff --git a/packages/agent-core/src/validation.ts b/packages/agent-core/src/validation.ts index 72d573bbbc3..1812d9d4fd8 100644 --- a/packages/agent-core/src/validation.ts +++ b/packages/agent-core/src/validation.ts @@ -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>(); -const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); - -interface JsonSchemaObject { - type?: string | string[]; - properties?: Record; - items?: JsonSchemaObject | JsonSchemaObject[]; - additionalProperties?: boolean | JsonSchemaObject; - allOf?: JsonSchemaObject[]; - anyOf?: JsonSchemaObject[]; - oneOf?: JsonSchemaObject[]; -} - -function isRecord(value: unknown): value is Record { - 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 | 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, schema: JsonSchemaObject): void { - const properties = schema.properties; - const definedKeys = new Set(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 { - 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"; diff --git a/packages/llm-core/package.json b/packages/llm-core/package.json new file mode 100644 index 00000000000..d850d6b5851 --- /dev/null +++ b/packages/llm-core/package.json @@ -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" + } +} diff --git a/packages/llm-core/src/index.ts b/packages/llm-core/src/index.ts new file mode 100644 index 00000000000..e618aaf4a42 --- /dev/null +++ b/packages/llm-core/src/index.ts @@ -0,0 +1,4 @@ +export * from "./types.js"; +export * from "./utils/diagnostics.js"; +export * from "./utils/event-stream.js"; +export * from "./validation.js"; diff --git a/packages/llm-core/src/types.ts b/packages/llm-core/src/types.ts new file mode 100644 index 00000000000..f19783e500b --- /dev/null +++ b/packages/llm-core/src/types.ts @@ -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>; + +/** 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 | Promise; + +export interface ProviderResponse { + status: number; + headers: Record; +} + +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; + /** + * Optional callback invoked after an HTTP response is received and before + * its body stream is consumed. + */ + onResponse?: (response: ProviderResponse, model: Model) => void | Promise; + /** + * 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; + /** + * 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; +} + +export type ProviderStreamOptions = StreamOptions & Record; + +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; + /** + * Optional callback invoked after an HTTP response is received. + */ + onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise; + /** + * Optional custom HTTP headers to include in API requests. + * Merged with provider defaults; can override default headers. + */ + headers?: Record; + /** + * 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; +} + +export type ProviderImagesOptions = ImagesOptions & Record; + +// 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, + context: Context, + options?: TOptions, +) => AssistantMessageEventStreamContract; + +export type ImagesFunction< + TApi extends ImagesApi = ImagesApi, + TOptions extends ImagesOptions = ImagesOptions, +> = ( + model: ImagesModel, + context: ImagesContext, + options?: TOptions, +) => Promise; + +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; + 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 { + 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 { + 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; + message: AssistantMessage; + } + | { type: "error"; reason: Extract; error: AssistantMessage }; + +export interface AssistantMessageEventStreamContract extends AsyncIterable { + push(event: AssistantMessageEvent): void; + end(result?: AssistantMessage): void; + result(): Promise; +} + +export interface AssistantMessageEventStreamLike extends AsyncIterable { + result(): Promise; +} + +/** + * 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 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 { + 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; + /** 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 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; + +export type CompleteSimpleFn = ( + model: Model, + context: Pick, + options?: SimpleStreamOptions, +) => Promise; + +export type ValidateToolArgumentsFn = (tool: Tool, toolCall: ToolCall) => unknown; diff --git a/packages/llm-core/src/utils/diagnostics.ts b/packages/llm-core/src/utils/diagnostics.ts new file mode 100644 index 00000000000..26db0db39e5 --- /dev/null +++ b/packages/llm-core/src/utils/diagnostics.ts @@ -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; +} + +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, +): AssistantMessageDiagnostic { + return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details }; +} + +export function appendAssistantMessageDiagnostic( + message: { diagnostics?: AssistantMessageDiagnostic[] }, + diagnostic: AssistantMessageDiagnostic, +): void { + message.diagnostics = [...(message.diagnostics ?? []), diagnostic]; +} diff --git a/packages/llm-core/src/utils/event-stream.ts b/packages/llm-core/src/utils/event-stream.ts new file mode 100644 index 00000000000..d4473035b63 --- /dev/null +++ b/packages/llm-core/src/utils/event-stream.ts @@ -0,0 +1,101 @@ +import type { + AssistantMessage, + AssistantMessageEvent, + AssistantMessageEventStreamContract, +} from "../types.js"; + +// Generic event stream class for async iteration +export class EventStream implements AsyncIterable { + private queue: T[] = []; + private waiting: ((value: IteratorResult) => void)[] = []; + private done = false; + private finalResultPromise: Promise; + 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 { + while (true) { + if (this.queue.length > 0) { + yield this.queue.shift()!; + } else if (this.done) { + return; + } else { + const result = await new Promise>((resolve) => + this.waiting.push(resolve), + ); + if (result.done) { + return; + } + yield result.value; + } + } + } + + result(): Promise { + return this.finalResultPromise; + } +} + +export class AssistantMessageEventStream + extends EventStream + 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(); +} diff --git a/packages/agent-core/src/validation.test.ts b/packages/llm-core/src/validation.test.ts similarity index 96% rename from packages/agent-core/src/validation.test.ts rename to packages/llm-core/src/validation.test.ts index d18a42b86dc..a35a5c83f43 100644 --- a/packages/agent-core/src/validation.test.ts +++ b/packages/llm-core/src/validation.test.ts @@ -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 = { diff --git a/packages/llm-core/src/validation.ts b/packages/llm-core/src/validation.ts new file mode 100644 index 00000000000..6d174d1976e --- /dev/null +++ b/packages/llm-core/src/validation.ts @@ -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>(); +const TYPEBOX_KIND = Symbol.for("TypeBox.Kind"); + +interface JsonSchemaObject { + type?: string | string[]; + properties?: Record; + items?: JsonSchemaObject | JsonSchemaObject[]; + additionalProperties?: boolean | JsonSchemaObject; + allOf?: JsonSchemaObject[]; + anyOf?: JsonSchemaObject[]; + oneOf?: JsonSchemaObject[]; +} + +function isRecord(value: unknown): value is Record { + 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 | 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, schema: JsonSchemaObject): void { + const properties = schema.properties; + const definedKeys = new Set(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 { + 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)}`, + ); +} diff --git a/packages/llm-core/tsconfig.json b/packages/llm-core/tsconfig.json new file mode 100644 index 00000000000..60c7df18111 --- /dev/null +++ b/packages/llm-core/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/packages/llm-runtime/package.json b/packages/llm-runtime/package.json new file mode 100644 index 00000000000..4999aba9618 --- /dev/null +++ b/packages/llm-runtime/package.json @@ -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:*" + } +} diff --git a/packages/llm-runtime/src/api-registry.test.ts b/packages/llm-runtime/src/api-registry.test.ts new file mode 100644 index 00000000000..498718b689d --- /dev/null +++ b/packages/llm-runtime/src/api-registry.test.ts @@ -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", + ); + }); +}); diff --git a/packages/llm-runtime/src/api-registry.ts b/packages/llm-runtime/src/api-registry.ts new file mode 100644 index 00000000000..363f70b6fee --- /dev/null +++ b/packages/llm-runtime/src/api-registry.ts @@ -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; + streamSimple: StreamFunction; +} + +interface ApiProviderInternal { + api: Api; + stream: ApiStreamFunction; + streamSimple: ApiStreamSimpleFunction; +} + +type RegisteredApiProvider = { + provider: ApiProviderInternal; + sourceId?: string; +}; + +const apiProviderRegistry = new Map(); + +function wrapStream( + api: TApi, + stream: StreamFunction, +): ApiStreamFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return stream(model as Model, context, options as TOptions); + }; +} + +function wrapStreamSimple( + api: TApi, + streamSimple: StreamFunction, +): ApiStreamSimpleFunction { + return (model, context, options) => { + if (model.api !== api) { + throw new Error(`Mismatched api: ${model.api} expected ${api}`); + } + return streamSimple(model as Model, context, options); + }; +} + +export function registerApiProvider( + provider: ApiProvider, + 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(); +} diff --git a/packages/llm-runtime/src/index.ts b/packages/llm-runtime/src/index.ts new file mode 100644 index 00000000000..c9b5df7b0f6 --- /dev/null +++ b/packages/llm-runtime/src/index.ts @@ -0,0 +1,9 @@ +export { + clearApiProviders, + getApiProvider, + getApiProviders, + registerApiProvider, + unregisterApiProviders, + type ApiProvider, +} from "./api-registry.js"; +export { complete, completeSimple, stream, streamSimple } from "./stream.js"; diff --git a/packages/llm-runtime/src/stream.ts b/packages/llm-runtime/src/stream.ts new file mode 100644 index 00000000000..aed573d2a20 --- /dev/null +++ b/packages/llm-runtime/src/stream.ts @@ -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( + model: Model, + context: Context, + options?: ProviderStreamOptions, +): AssistantMessageEventStreamContract { + const provider = resolveApiProvider(model.api); + return provider.stream(model, context, options as StreamOptions); +} + +export async function complete( + model: Model, + context: Context, + options?: ProviderStreamOptions, +): Promise { + const s = stream(model, context, options); + return s.result(); +} + +export function streamSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): AssistantMessageEventStreamContract { + const provider = resolveApiProvider(model.api); + return provider.streamSimple(model, context, options); +} + +export async function completeSimple( + model: Model, + context: Context, + options?: SimpleStreamOptions, +): Promise { + const s = streamSimple(model, context, options); + return s.result(); +} diff --git a/packages/llm-runtime/tsconfig.json b/packages/llm-runtime/tsconfig.json new file mode 100644 index 00000000000..60c7df18111 --- /dev/null +++ b/packages/llm-runtime/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": ["src/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6acf766877..c2eb77f872a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/scripts/build-all.mjs b/scripts/build-all.mjs index 3d221576bc5..decfab23dc0 100644 --- a/scripts/build-all.mjs +++ b/scripts/build-all.mjs @@ -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", diff --git a/scripts/lib/extension-package-boundary.ts b/scripts/lib/extension-package-boundary.ts index 32219b89f64..254e4b6ef85 100644 --- a/scripts/lib/extension-package-boundary.ts +++ b/scripts/lib/extension-package-boundary.ts @@ -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"], diff --git a/scripts/prepare-extension-package-boundary-artifacts.mjs b/scripts/prepare-extension-package-boundary-artifacts.mjs index 60820c8d57f..ebd6ff510fc 100644 --- a/scripts/prepare-extension-package-boundary-artifacts.mjs +++ b/scripts/prepare-extension-package-boundary-artifacts.mjs @@ -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", diff --git a/src/agents/bash-tools.exec-host-node.test.ts b/src/agents/bash-tools.exec-host-node.test.ts index 77f03ba5356..c7c4a0b9688 100644 --- a/src/agents/bash-tools.exec-host-node.test.ts +++ b/src/agents/bash-tools.exec-host-node.test.ts @@ -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", diff --git a/src/agents/runtime/index.ts b/src/agents/runtime/index.ts index 3790410510d..1f9a409f63f 100644 --- a/src/agents/runtime/index.ts +++ b/src/agents/runtime/index.ts @@ -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 = { diff --git a/src/agents/sessions/compaction/compaction.ts b/src/agents/sessions/compaction/compaction.ts index d868c379d16..78621f8fa4f 100644 --- a/src/agents/sessions/compaction/compaction.ts +++ b/src/agents/sessions/compaction/compaction.ts @@ -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, diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts index 51f1dcfc280..ad0d600b3c1 100644 --- a/src/agents/simple-completion-runtime.ts +++ b/src/agents/simple-completion-runtime.ts @@ -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[1]; cfg?: OpenClawConfig; options?: SimpleCompletionModelOptions; -}) { +}): Promise { const completionModel = prepareModelForSimpleCompletion({ model: params.model, cfg: params.cfg }); const { reasoning: rawReasoning, ...options } = params.options ?? {}; const reasoning = normalizeSimpleCompletionReasoning(rawReasoning); diff --git a/src/llm/api-registry.ts b/src/llm/api-registry.ts index a32b280b6aa..10902365f86 100644 --- a/src/llm/api-registry.ts +++ b/src/llm/api-registry.ts @@ -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; - streamSimple: StreamFunction; -} - -interface ApiProviderInternal { - api: Api; - stream: ApiStreamFunction; - streamSimple: ApiStreamSimpleFunction; -} - -type RegisteredApiProvider = { - provider: ApiProviderInternal; - sourceId?: string; -}; - -const apiProviderRegistry = new Map(); - -function wrapStream( - api: TApi, - stream: StreamFunction, -): ApiStreamFunction { - return (model, context, options) => { - if (model.api !== api) { - throw new Error(`Mismatched api: ${model.api} expected ${api}`); - } - return stream(model as Model, context, options as TOptions); - }; -} - -function wrapStreamSimple( - api: TApi, - streamSimple: StreamFunction, -): ApiStreamSimpleFunction { - return (model, context, options) => { - if (model.api !== api) { - throw new Error(`Mismatched api: ${model.api} expected ${api}`); - } - return streamSimple(model as Model, context, options); - }; -} - -export function registerApiProvider( - provider: ApiProvider, - 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"; diff --git a/src/llm/providers/register-builtins.ts b/src/llm/providers/register-builtins.ts index b90557c18c1..b8406c45df6 100644 --- a/src/llm/providers/register-builtins.ts +++ b/src/llm/providers/register-builtins.ts @@ -404,5 +404,3 @@ export function resetApiProviders(): void { unregisterApiProviders(BUILT_IN_API_PROVIDER_SOURCE_ID); registerBuiltInApiProviders(); } - -registerBuiltInApiProviders(); diff --git a/src/llm/stream.ts b/src/llm/stream.ts index 590abb38578..f73c73bff50 100644 --- a/src/llm/stream.ts +++ b/src/llm/stream.ts @@ -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( - model: Model, - context: Context, - options?: ProviderStreamOptions, -): AssistantMessageEventStreamContract { - const provider = resolveApiProvider(model.api); - return provider.stream(model, context, options as StreamOptions); -} - -export async function complete( - model: Model, - context: Context, - options?: ProviderStreamOptions, -): Promise { - const s = stream(model, context, options); - return s.result(); -} - -export function streamSimple( - model: Model, - context: Context, - options?: SimpleStreamOptions, -): AssistantMessageEventStreamContract { - const provider = resolveApiProvider(model.api); - return provider.streamSimple(model, context, options); -} - -export async function completeSimple( - model: Model, - context: Context, - options?: SimpleStreamOptions, -): Promise { - const s = streamSimple(model, context, options); - return s.result(); -} diff --git a/src/llm/types.ts b/src/llm/types.ts index cf3be032ead..8cb69fa3284 100644 --- a/src/llm/types.ts +++ b/src/llm/types.ts @@ -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>; - -/** 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 | Promise; - -export interface ProviderResponse { - status: number; - headers: Record; -} - -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; - /** - * Optional callback invoked after an HTTP response is received and before - * its body stream is consumed. - */ - onResponse?: (response: ProviderResponse, model: Model) => void | Promise; - /** - * 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; - /** - * 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; -} - -export type ProviderStreamOptions = StreamOptions & Record; - -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; - /** - * Optional callback invoked after an HTTP response is received. - */ - onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise; - /** - * Optional custom HTTP headers to include in API requests. - * Merged with provider defaults; can override default headers. - */ - headers?: Record; - /** - * 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; -} - -export type ProviderImagesOptions = ImagesOptions & Record; - -// 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, - context: Context, - options?: TOptions, -) => AssistantMessageEventStreamContract; - -export type ImagesFunction< - TApi extends ImagesApi = ImagesApi, - TOptions extends ImagesOptions = ImagesOptions, -> = ( - model: ImagesModel, - context: ImagesContext, - options?: TOptions, -) => Promise; - -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; - 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 { - 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 { - 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; - message: AssistantMessage; - } - | { type: "error"; reason: Extract; error: AssistantMessage }; - -export interface AssistantMessageEventStreamContract extends AsyncIterable { - push(event: AssistantMessageEvent): void; - end(result?: AssistantMessage): void; - result(): Promise; -} - -/** - * 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 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 { - 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; - /** 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 extends Omit< - Model, - "api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat" -> { - api: TApi; - provider: ImagesProvider; - output: ("text" | "image")[]; -} +export * from "../../packages/llm-core/src/types.js"; diff --git a/src/llm/utils/diagnostics.ts b/src/llm/utils/diagnostics.ts index 26db0db39e5..85b4780dfb3 100644 --- a/src/llm/utils/diagnostics.ts +++ b/src/llm/utils/diagnostics.ts @@ -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; -} - -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, -): 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"; diff --git a/src/llm/utils/event-stream.ts b/src/llm/utils/event-stream.ts index d728de1a446..53752594792 100644 --- a/src/llm/utils/event-stream.ts +++ b/src/llm/utils/event-stream.ts @@ -1,97 +1 @@ -import type { AssistantMessage, AssistantMessageEvent } from "../types.js"; - -// Generic event stream class for async iteration -export class EventStream implements AsyncIterable { - private queue: T[] = []; - private waiting: ((value: IteratorResult) => void)[] = []; - private done = false; - private finalResultPromise: Promise; - 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 { - while (true) { - if (this.queue.length > 0) { - yield this.queue.shift()!; - } else if (this.done) { - return; - } else { - const result = await new Promise>((resolve) => - this.waiting.push(resolve), - ); - if (result.done) { - return; - } - yield result.value; - } - } - } - - result(): Promise { - 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"; diff --git a/src/plugin-sdk/agent-core.ts b/src/plugin-sdk/agent-core.ts index d3f1c8b56ab..5b12b6187fd 100644 --- a/src/plugin-sdk/agent-core.ts +++ b/src/plugin-sdk/agent-core.ts @@ -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 = { diff --git a/src/plugin-sdk/llm.ts b/src/plugin-sdk/llm.ts index 95e8e6fecaa..89b80bb4c02 100644 --- a/src/plugin-sdk/llm.ts +++ b/src/plugin-sdk/llm.ts @@ -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"; diff --git a/test/scripts/crabbox-wrapper.test.ts b/test/scripts/crabbox-wrapper.test.ts index fc0ecb8b322..760ce8b22d2 100644 --- a/test/scripts/crabbox-wrapper.test.ts +++ b/test/scripts/crabbox-wrapper.test.ts @@ -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([ diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index a0b9ce5db05..1a8062695aa 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -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"), diff --git a/tsconfig.json b/tsconfig.json index 8a09a07a8b9..10bb13102fb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"], diff --git a/tsconfig.plugin-sdk.dts.json b/tsconfig.plugin-sdk.dts.json index b7b4321374a..3e4c4a6e291 100644 --- a/tsconfig.plugin-sdk.dts.json +++ b/tsconfig.plugin-sdk.dts.json @@ -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", diff --git a/tsdown.config.ts b/tsdown.config.ts index 82d66d2da8e..36c5cd3c3e4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -393,8 +393,28 @@ function buildSpeechCoreDistEntries(): Record { }; } +function buildLlmCoreDistEntries(): Record { + 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 { + 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.