Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

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

View File

@@ -0,0 +1,6 @@
/** Public LLM core contracts shared by providers, plugin SDK wrappers, and tests. */
export * from "./model-contracts/anthropic.js";
export * from "./types.js";
export * from "./utils/diagnostics.js";
export * from "./utils/event-stream.js";
export * from "./validation.js";

View File

@@ -0,0 +1,91 @@
type ClaudeModelRef = {
id?: string;
params?: Record<string, unknown>;
};
type ClaudeEffortModelRef = ClaudeModelRef & {
thinkingLevelMap?: Record<string, string | null | undefined>;
};
function normalizeClaudeModelId(modelId?: string): string {
const normalized = modelId?.trim().toLowerCase() ?? "";
const unprefixed = normalized.startsWith("anthropic/")
? normalized.slice("anthropic/".length)
: normalized;
return unprefixed.replace(/[._\s]+/g, "-");
}
export const CLAUDE_FABLE_5_THINKING_PROFILE = {
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "xhigh" },
{ id: "adaptive" },
{ id: "max" },
],
defaultLevel: "high",
preserveWhenCatalogReasoningFalse: true,
} as const;
/** Resolve the canonical normalized Claude model id for one runtime model ref. */
export function resolveClaudeModelIdentity(ref: ClaudeModelRef): string {
const configuredCanonicalModelId =
typeof ref.params?.canonicalModelId === "string" ? ref.params.canonicalModelId : undefined;
const normalized = normalizeClaudeModelId(configuredCanonicalModelId ?? ref.id);
const match = /(?:^|[-/])claude-/.exec(normalized);
return match
? normalized.slice((match.index ?? 0) + (match[0].startsWith("claude-") ? 0 : 1))
: normalized;
}
/** Resolve Claude Fable 5 through direct ids, cloud ids, or deployment metadata. */
export function resolveClaudeFable5ModelIdentity(ref: ClaudeModelRef): string | undefined {
const normalized = resolveClaudeModelIdentity(ref);
const match = /(?:^|-)claude-fable-5(?=$|[^a-z0-9])/.exec(normalized);
if (!match) {
return undefined;
}
return normalized.slice((match.index ?? 0) + (match[0].startsWith("-") ? 1 : 0));
}
/** Return whether a Claude model supports adaptive thinking. */
export function supportsClaudeAdaptiveThinking(ref: ClaudeModelRef): boolean {
const modelId = resolveClaudeModelIdentity(ref);
return /(?:^|-)claude-(?:fable-5|mythos-preview|opus-4-(?:6|7|8)|sonnet-4-6)(?=$|[^a-z0-9])/.test(
modelId,
);
}
/** Return whether a Claude model supports native max effort. */
export function supportsClaudeNativeMaxEffort(ref: ClaudeModelRef): boolean {
const modelId = resolveClaudeModelIdentity(ref);
return /(?:^|-)claude-(?:fable-5|opus-4-(?:6|7|8)|sonnet-4-6)(?=$|[^a-z0-9])/.test(modelId);
}
/** Return whether a Claude model supports native xhigh effort. */
export function supportsClaudeNativeXhighEffort(ref: ClaudeModelRef): boolean {
const modelId = resolveClaudeModelIdentity(ref);
return /(?:^|-)claude-(?:fable-5|opus-4-(?:7|8))(?=$|[^a-z0-9])/.test(modelId);
}
/**
* Fill native Claude effort mappings only when the provider did not publish a
* narrower route-specific contract.
*/
export function resolveClaudeNativeThinkingLevelMap(
ref: ClaudeEffortModelRef,
): Record<string, string | null | undefined> | undefined {
if (ref.thinkingLevelMap !== undefined) {
return ref.thinkingLevelMap;
}
if (!supportsClaudeNativeMaxEffort(ref)) {
return undefined;
}
return {
xhigh: supportsClaudeNativeXhighEffort(ref) ? "xhigh" : null,
max: "max",
};
}

View File

@@ -0,0 +1,659 @@
// LLM Core type module defines shared TypeScript contracts.
export type { AssistantMessageDiagnostic, DiagnosticErrorInfo } from "./utils/diagnostics.js";
import type { AssistantMessageDiagnostic } from "./utils/diagnostics.js";
/** Provider API families with first-class request/stream adapters in OpenClaw. */
export type KnownApi =
| "openai-completions"
| "mistral-conversations"
| "openai-responses"
| "azure-openai-responses"
| "openai-chatgpt-responses"
| "anthropic-messages"
| "bedrock-converse-stream"
| "google-generative-ai"
| "google-vertex";
/** Provider API id; custom providers can use ids outside the built-in set. */
export type Api = KnownApi | (string & {});
/** Image-generation API families with first-class adapters in OpenClaw. */
export type KnownImagesApi = "openrouter-images";
/** Image API id; custom image providers can use ids outside the built-in set. */
export type ImagesApi = KnownImagesApi | (string & {});
/** Provider id used for routing, diagnostics, and config lookups. */
export type Provider = string;
/** Image provider ids with first-class adapters in OpenClaw. */
export type KnownImagesProvider = "openrouter";
/** Image provider id used for routing, diagnostics, and config lookups. */
export type ImagesProvider = string;
/** Normalized reasoning-effort levels shared across provider-specific knobs. */
export type ThinkingLevel = "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
/** Model thinking setting including explicit disabled state. */
export type ModelThinkingLevel = "off" | ThinkingLevel;
/** Provider-specific values for normalized thinking levels. */
export type ThinkingLevelMap = Partial<Record<ModelThinkingLevel, string | null>>;
/** Token budgets for each thinking level (token-based providers only) */
export interface ThinkingBudgets {
minimal?: number;
low?: number;
medium?: number;
high?: number;
max?: number;
}
/** Prompt-cache retention preference shared by providers that expose cache controls. */
export type CacheRetention = "none" | "short" | "long";
/** Streaming transport preference for providers that support multiple transports. */
export type Transport = "sse" | "websocket" | "websocket-cached" | "auto";
/** Helper for hooks that may be synchronous or asynchronous. */
export type MaybePromise<T> = T | Promise<T>;
/** Minimal HTTP response metadata surfaced through provider hooks. */
export interface ProviderResponse {
status: number;
headers: Record<string, string>;
}
/** Request options shared by text streaming providers. */
export interface StreamOptions {
temperature?: number;
maxTokens?: number;
/**
* Stop sequences forwarded to providers that support them. Providers map this
* to their native request field, such as OpenAI `stop` or Anthropic
* `stop_sequences`.
*/
stop?: string[];
signal?: AbortSignal;
apiKey?: string;
/**
* Preferred transport for providers that support multiple transports.
* Providers that do not support this option ignore it.
*/
transport?: Transport;
/**
* Prompt cache retention preference. Providers map this to their supported values.
* Default: "short".
*/
cacheRetention?: CacheRetention;
/**
* Optional session identifier for providers that support session-based caching.
* Providers can use this to enable prompt caching, request routing, or other
* session-aware features. Ignored by providers that don't support it.
*/
sessionId?: string;
/**
* Optional provider prompt-cache affinity key, distinct from transcript/session identity.
* Providers that do not support separate cache affinity ignore it.
*/
promptCacheKey?: string;
/**
* Optional callback for inspecting or replacing provider payloads before sending.
* Return undefined to keep the payload unchanged.
*/
onPayload?: (payload: unknown, model: Model) => MaybePromise<unknown>;
/**
* Optional callback invoked after an HTTP response is received and before
* its body stream is consumed.
*/
onResponse?: (response: ProviderResponse, model: Model) => void | Promise<void>;
/**
* Optional custom HTTP headers to include in API requests.
* Merged with provider defaults; can override default headers.
* Not supported by all providers (e.g., AWS Bedrock uses SDK auth).
*/
headers?: Record<string, string>;
/**
* HTTP request timeout in milliseconds for providers/SDKs that support it.
* For example, OpenAI and Anthropic SDK clients default to 10 minutes.
*/
timeoutMs?: number;
/**
* Maximum retry attempts for providers/SDKs that support client-side retries.
* For example, OpenAI and Anthropic SDK clients default to 2.
*/
maxRetries?: number;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately
* with an error containing the requested delay, allowing higher-level retry logic
* to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Optional metadata to include in API requests.
* Providers extract the fields they understand and ignore the rest.
* For example, Anthropic uses `user_id` for abuse tracking and rate limiting.
*/
metadata?: Record<string, unknown>;
}
export type ProviderStreamOptions = StreamOptions & Record<string, unknown>;
/** Request options shared by image-generation providers. */
export interface ImagesOptions {
signal?: AbortSignal;
apiKey?: string;
/**
* Optional callback for inspecting or replacing provider payloads before sending.
* Return undefined to keep the payload unchanged.
*/
onPayload?: (payload: unknown, model: ImagesModel) => MaybePromise<unknown>;
/**
* Optional callback invoked after an HTTP response is received.
*/
onResponse?: (response: ProviderResponse, model: ImagesModel) => void | Promise<void>;
/**
* Optional custom HTTP headers to include in API requests.
* Merged with provider defaults; can override default headers.
*/
headers?: Record<string, string>;
/**
* HTTP request timeout in milliseconds for providers/SDKs that support it.
*/
timeoutMs?: number;
/**
* Maximum retry attempts for providers/SDKs that support client-side retries.
*/
maxRetries?: number;
/**
* Maximum delay in milliseconds to wait for a retry when the server requests a long wait.
* If the server's requested delay exceeds this value, the request fails immediately
* with an error containing the requested delay, allowing higher-level retry logic
* to handle it with user visibility.
* Default: 60000 (60 seconds). Set to 0 to disable the cap.
*/
maxRetryDelayMs?: number;
/**
* Optional metadata to include in API requests.
* Providers extract the fields they understand and ignore the rest.
*/
metadata?: Record<string, unknown>;
}
export type ProviderImagesOptions = ImagesOptions & Record<string, unknown>;
/** Unified text options used by simple completion helpers. */
export interface SimpleStreamOptions extends StreamOptions {
reasoning?: ThinkingLevel;
/** Custom token budgets for thinking levels (token-based providers only) */
thinkingBudgets?: ThinkingBudgets;
}
// Generic StreamFunction with typed options.
//
// Contract:
// - Must return an AssistantMessageEventStream.
// - Once invoked, request/model/runtime failures should be encoded in the
// returned stream, not thrown.
// - Error termination must produce an AssistantMessage with stopReason
// "error" or "aborted" and errorMessage, emitted via the stream protocol.
export type StreamFunction<
TApi extends Api = Api,
TOptions extends StreamOptions = StreamOptions,
> = (
model: Model<TApi>,
context: Context,
options?: TOptions,
) => AssistantMessageEventStreamContract;
export type ImagesFunction<
TApi extends ImagesApi = ImagesApi,
TOptions extends ImagesOptions = ImagesOptions,
> = (
model: ImagesModel<TApi>,
context: ImagesContext,
options?: TOptions,
) => Promise<AssistantImages>;
export interface TextSignatureV1 {
v: 1;
id: string;
phase?: "commentary" | "final_answer";
}
/** Plain assistant/user text content block. */
export interface TextContent {
type: "text";
text: string;
textSignature?: string; // e.g., for OpenAI responses, message metadata (legacy id string or TextSignatureV1 JSON)
}
/** Provider reasoning/thinking content block, including opaque replay signatures. */
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;
}
/** Base64 image content block with MIME type metadata. */
export interface ImageContent {
type: "image";
data: string; // base64 encoded image data
mimeType: string; // e.g., "image/jpeg", "image/png"
}
/** Normalized assistant tool call emitted by providers or repaired from text. */
export interface ToolCall {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, unknown>;
thoughtSignature?: string; // Google-specific: opaque signature for reusing thought context
executionMode?: "sequential" | "parallel";
}
/** Normalized token and cost accounting for a provider response. */
export interface Usage {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
/** Exact context snapshot for the final provider iteration. */
contextUsage?:
| { state: "available"; promptTokens: number; totalTokens: number }
| { state: "unavailable" };
totalTokens: number;
cost: {
input: number;
output: number;
cacheRead: number;
cacheWrite: number;
total: number;
};
}
/** Normalized assistant stop reasons across text providers. */
export type StopReason = "stop" | "length" | "toolUse" | "error" | "aborted";
/** User turn in a text-model conversation. */
export interface UserMessage {
role: "user";
content: string | (TextContent | ImageContent)[];
timestamp: number; // Unix timestamp in milliseconds
}
/** Assistant turn, including provider identity and final stop state. */
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;
errorCode?: string;
errorType?: string;
errorBody?: string;
timestamp: number; // Unix timestamp in milliseconds
}
/** Tool result turn that answers a prior assistant tool call. */
export interface ToolResultMessage<TDetails = unknown> {
role: "toolResult";
toolCallId: string;
toolName: string;
content: (TextContent | ImageContent)[]; // Supports text and images
details?: TDetails;
isError: boolean;
timestamp: number; // Unix timestamp in milliseconds
}
/** Any text-model conversation message supported by LLM core. */
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
/** Image request input content accepted by image providers. */
export type ImagesInputContent = TextContent | ImageContent;
/** Image response output content returned by image providers. */
export type ImagesOutputContent = TextContent | ImageContent;
/** Image-generation request context. */
export interface ImagesContext {
input: ImagesInputContent[];
}
/** Normalized image-generation stop reasons. */
export type ImagesStopReason = "stop" | "error" | "aborted";
/** Final image-generation response shape. */
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";
/** Provider tool declaration with a TypeBox/JSON-schema parameter object. */
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
description: string;
parameters: TParameters;
}
/** Text-model request context shared by provider adapters. */
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 }
/**
* Plain text deltas may omit `partial` to avoid retaining one full assistant
* snapshot per token. Consumers that need current text should replay `delta`
* from the latest start/end partial checkpoint.
*/
| { type: "text_delta"; contentIndex: number; delta: string; partial?: AssistantMessage }
| { type: "text_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "thinking_start"; contentIndex: number; partial: AssistantMessage }
| { type: "thinking_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "thinking_end"; contentIndex: number; content: string; partial: AssistantMessage }
| { type: "toolcall_start"; contentIndex: number; partial: AssistantMessage }
| { type: "toolcall_delta"; contentIndex: number; delta: string; partial: AssistantMessage }
| { type: "toolcall_end"; contentIndex: number; toolCall: ToolCall; partial: AssistantMessage }
| {
type: "done";
reason: Extract<StopReason, "stop" | "length" | "toolUse">;
message: AssistantMessage;
}
| { type: "error"; reason: Extract<StopReason, "aborted" | "error">; error: AssistantMessage };
export interface AssistantMessageEventStreamContract extends AsyncIterable<AssistantMessageEvent> {
/** Queue one stream event for consumers. */
push(event: AssistantMessageEvent): void;
/** Complete the stream and optionally resolve the final message. */
end(result?: AssistantMessage): void;
/** Final assistant message produced by the stream. */
result(): Promise<AssistantMessage>;
}
/** Read-only stream contract accepted by consumers that do not need to push events. */
export interface AssistantMessageEventStreamLike extends AsyncIterable<AssistantMessageEvent> {
result(): Promise<AssistantMessage>;
}
/**
* Compatibility settings for OpenAI-compatible completions APIs.
* Use this to override URL-based auto-detection for custom providers.
*/
export interface OpenAICompletionsCompat {
/** Whether the provider supports the `store` field. Default: auto-detected from URL. */
supportsStore?: boolean;
/** Whether the provider supports the `developer` role (vs `system`). Default: auto-detected from URL. */
supportsDeveloperRole?: boolean;
/** Whether the provider supports `reasoning_effort`. Default: auto-detected from URL. */
supportsReasoningEffort?: boolean;
/** Whether the provider supports `stream_options: { include_usage: true }` for token usage in streaming responses. Default: true. */
supportsUsageInStreaming?: boolean;
/** Which field to use for max tokens. Default: auto-detected from URL. */
maxTokensField?: "max_completion_tokens" | "max_tokens";
/** Whether tool results require the `name` field. Default: auto-detected from URL. */
requiresToolResultName?: boolean;
/** Whether a user message after tool results requires an assistant message in between. Default: auto-detected from URL. */
requiresAssistantAfterToolResult?: boolean;
/** Whether thinking blocks must be converted to text blocks with <thinking> delimiters. Default: auto-detected from URL. */
requiresThinkingAsText?: boolean;
/** Whether all replayed assistant messages must include an empty reasoning_content field when reasoning is enabled. Default: auto-detected from URL. */
requiresReasoningContentOnAssistantMessages?: boolean;
/** Format for reasoning/thinking parameter. "openai" uses reasoning_effort, "openrouter" uses reasoning: { effort }, "deepseek" uses thinking: { type } plus reasoning_effort, "together" uses reasoning: { enabled } plus reasoning_effort when supported, "zai" uses top-level enable_thinking: boolean, "qwen" uses top-level enable_thinking: boolean, and "qwen-chat-template" uses chat_template_kwargs.enable_thinking. Default: "openai". */
thinkingFormat?:
| "openai"
| "openrouter"
| "deepseek"
| "together"
| "zai"
| "qwen"
| "qwen-chat-template";
/** OpenRouter-specific routing preferences. Only used when baseUrl points to OpenRouter. */
openRouterRouting?: OpenRouterRouting;
/** Vercel AI Gateway routing preferences. Only used when baseUrl points to Vercel AI Gateway. */
vercelGatewayRouting?: VercelGatewayRouting;
/** Whether z.ai supports top-level `tool_stream: true` for streaming tool call deltas. Default: false. */
zaiToolStream?: boolean;
/** Whether the provider supports the `strict` field in tool definitions. Default: true. */
supportsStrictMode?: boolean;
/** Cache control convention for prompt caching. "anthropic" applies Anthropic-style `cache_control` markers to the system prompt, last tool definition, and last user/assistant text content. */
cacheControlFormat?: "anthropic";
/** Whether to send known session-affinity headers (`session_id`, `x-client-request-id`, `x-session-affinity`) from `options.sessionId` when caching is enabled. Default: false. */
sendSessionAffinityHeaders?: boolean;
/** Whether the provider supports OpenAI-style `prompt_cache_key`. Default: false for third-party completions providers. */
supportsPromptCacheKey?: boolean;
/** Whether the provider supports long prompt cache retention (`prompt_cache_retention: "24h"` or Anthropic-style `cache_control.ttl: "1h"`, depending on format). Default: true. */
supportsLongCacheRetention?: boolean;
}
/** Compatibility settings for OpenAI Responses APIs. */
export interface OpenAIResponsesCompat {
/** Whether to send the OpenAI `session_id` cache-affinity header from `options.sessionId` when caching is enabled. Default: true. */
sendSessionIdHeader?: boolean;
/** Whether the provider supports `prompt_cache_retention: "24h"`. Default: true. */
supportsLongCacheRetention?: boolean;
}
/** Compatibility settings for Anthropic Messages-compatible APIs. */
export interface AnthropicMessagesCompat {
/**
* Whether the provider accepts per-tool `eager_input_streaming`.
* When false, the Anthropic provider omits `tools[].eager_input_streaming`
* and sends the legacy `fine-grained-tool-streaming-2025-05-14` beta header
* for tool-enabled requests.
* Default: true.
*/
supportsEagerToolInputStreaming?: boolean;
/** Whether the provider supports Anthropic long cache retention (`cache_control.ttl: "1h"`). Default: true. */
supportsLongCacheRetention?: boolean;
/**
* Whether to send the `x-session-affinity` header from `options.sessionId`
* when caching is enabled. Required for providers like Fireworks that use
* session affinity for prompt cache routing (requests to the same replica
* maximize cache hits).
* Default: false.
*/
sendSessionAffinityHeaders?: boolean;
/**
* Whether the provider supports Anthropic-style `cache_control` markers on
* tool definitions. When false, `cache_control` is omitted from tool params.
* Some Anthropic-compatible providers (e.g., Fireworks) do not support this
* field on tools and may reject or ignore it.
* Default: true.
*/
supportsCacheControlOnTools?: boolean;
}
/**
* OpenRouter provider routing preferences.
* Controls which upstream providers OpenRouter routes requests to.
* Sent as the `provider` field in the OpenRouter API request body.
* @see https://openrouter.ai/docs/guides/routing/provider-selection
*/
export interface OpenRouterRouting {
/** Whether to allow backup providers to serve requests. Default: true. */
allow_fallbacks?: boolean;
/** Whether to filter providers to only those that support all parameters in the request. Default: false. */
require_parameters?: boolean;
/** Data collection setting. "allow" (default): allow providers that may store/train on data. "deny": only use providers that don't collect user data. */
data_collection?: "deny" | "allow";
/** Whether to restrict routing to only ZDR (Zero Data Retention) endpoints. */
zdr?: boolean;
/** Whether to restrict routing to only models that allow text distillation. */
enforce_distillable_text?: boolean;
/** An ordered list of provider names/slugs to try in sequence, falling back to the next if unavailable. */
order?: string[];
/** List of provider names/slugs to exclusively allow for this request. */
only?: string[];
/** List of provider names/slugs to skip for this request. */
ignore?: string[];
/** A list of quantization levels to filter providers by (e.g., ["fp16", "bf16", "fp8", "fp6", "int8", "int4", "fp4", "fp32"]). */
quantizations?: string[];
/** Sorting strategy. Can be a string (e.g., "price", "throughput", "latency") or an object with `by` and `partition`. */
sort?:
| string
| {
/** The sorting metric: "price", "throughput", "latency". */
by?: string;
/** Partitioning strategy: "model" (default) or "none". */
partition?: string | null;
};
/** Maximum price per million tokens (USD). */
max_price?: {
/** Price per million prompt tokens. */
prompt?: number | string;
/** Price per million completion tokens. */
completion?: number | string;
/** Price per image. */
image?: number | string;
/** Price per audio unit. */
audio?: number | string;
/** Price per request. */
request?: number | string;
};
/** Preferred minimum throughput (tokens/second). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */
preferred_min_throughput?:
| number
| {
/** Minimum tokens/second at the 50th percentile. */
p50?: number;
/** Minimum tokens/second at the 75th percentile. */
p75?: number;
/** Minimum tokens/second at the 90th percentile. */
p90?: number;
/** Minimum tokens/second at the 99th percentile. */
p99?: number;
};
/** Preferred maximum latency (seconds). Can be a number (applies to p50) or an object with percentile-specific cutoffs. */
preferred_max_latency?:
| number
| {
/** Maximum latency in seconds at the 50th percentile. */
p50?: number;
/** Maximum latency in seconds at the 75th percentile. */
p75?: number;
/** Maximum latency in seconds at the 90th percentile. */
p90?: number;
/** Maximum latency in seconds at the 99th percentile. */
p99?: number;
};
}
/**
* Vercel AI Gateway routing preferences.
* Controls which upstream providers the gateway routes requests to.
* @see https://vercel.com/docs/ai-gateway/models-and-providers/provider-options
*/
export interface VercelGatewayRouting {
/** List of provider slugs to exclusively use for this request (e.g., ["bedrock", "anthropic"]). */
only?: string[];
/** List of provider slugs to try in order (e.g., ["anthropic", "openai"]). */
order?: string[];
}
// Model interface for the unified model system
export interface Model<TApi extends Api = Api> {
id: string;
name: string;
api: TApi;
provider: Provider;
baseUrl: string;
reasoning: boolean;
/**
* Maps OpenClaw thinking levels to provider/model-specific values.
* Missing keys use provider defaults. null marks a level as unsupported.
*/
thinkingLevelMap?: ThinkingLevelMap;
input: ("text" | "image")[];
cost: {
input: number; // $/million tokens
output: number; // $/million tokens
cacheRead: number; // $/million tokens
cacheWrite: number; // $/million tokens
};
contextWindow: number;
/**
* Optional effective runtime cap used for compaction/session budgeting.
* Keeps provider/native contextWindow metadata intact while allowing a
* smaller practical window.
*/
contextTokens?: number;
maxTokens: number;
/** Provider-specific request/runtime parameters passed through to provider plugins. */
params?: Record<string, unknown>;
headers?: Record<string, string>;
/** Sends runtime credentials as Authorization: Bearer instead of provider-specific key headers. */
authHeader?: boolean;
/** 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;
/** Provider-documented media input limits used by attachment preprocessing. */
mediaInput?: {
image?: {
maxBytes?: number;
maxPixels?: number;
maxSidePx?: number;
preferredSidePx?: number;
tokenMode?: "tile" | "detail" | "provider";
};
};
}
export interface ImagesModel<TApi extends ImagesApi = ImagesApi> extends Omit<
Model,
"api" | "provider" | "reasoning" | "contextWindow" | "maxTokens" | "compat"
> {
api: TApi;
provider: ImagesProvider;
output: ("text" | "image")[];
}
export type StreamFn = (
model: Model,
context: Context,
options?: SimpleStreamOptions,
) => AssistantMessageEventStreamLike | Promise<AssistantMessageEventStreamLike>;
export type CompleteSimpleFn = (
model: Model,
context: Pick<Context, "systemPrompt" | "messages">,
options?: SimpleStreamOptions,
) => Promise<AssistantMessage>;
export type ValidateToolArgumentsFn = (tool: Tool, toolCall: ToolCall) => unknown;

View File

@@ -0,0 +1,56 @@
// LLM Core module implements diagnostics behavior.
export interface DiagnosticErrorInfo {
name?: string;
message: string;
stack?: string;
code?: string | number;
}
export interface AssistantMessageDiagnostic {
type: string;
timestamp: number;
error?: DiagnosticErrorInfo;
details?: Record<string, unknown>;
}
/** Formats arbitrary thrown values into diagnostic-safe text. */
export function formatThrownValue(value: unknown): string {
if (value instanceof Error) {
return value.message || value.name;
}
if (typeof value === "string") {
return value;
}
return String(value);
}
/** Extracts serializable diagnostic error fields from Error and non-Error throws. */
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,
};
}
/** Creates a timestamped assistant-message diagnostic entry. */
export function createAssistantMessageDiagnostic(
type: string,
error: unknown,
details?: Record<string, unknown>,
): AssistantMessageDiagnostic {
return { type, timestamp: Date.now(), error: extractDiagnosticError(error), details };
}
/** Appends a diagnostic while preserving existing message diagnostics. */
export function appendAssistantMessageDiagnostic(
message: { diagnostics?: AssistantMessageDiagnostic[] },
diagnostic: AssistantMessageDiagnostic,
): void {
message.diagnostics = [...(message.diagnostics ?? []), diagnostic];
}

View File

@@ -0,0 +1,101 @@
// LLM Core module implements event stream behavior.
import type {
AssistantMessage,
AssistantMessageEvent,
AssistantMessageEventStreamContract,
} from "../types.js";
/** Generic async-iterable event stream with a separately awaited final result. */
export class EventStream<T, R = T> implements AsyncIterable<T> {
private queue: T[] = [];
private waiting: ((value: IteratorResult<T>) => void)[] = [];
private done = false;
private finalResultPromise: Promise<R>;
private resolveFinalResult!: (result: R) => void;
private isComplete: (event: T) => boolean;
private extractResult: (event: T) => R;
constructor(isComplete: (event: T) => boolean, extractResult: (event: T) => R) {
this.isComplete = isComplete;
this.extractResult = extractResult;
this.finalResultPromise = new Promise((resolve) => {
this.resolveFinalResult = resolve;
});
}
push(event: T): void {
if (this.done) {
return;
}
if (this.isComplete(event)) {
this.done = true;
this.resolveFinalResult(this.extractResult(event));
}
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) {
const waiter = this.waiting.shift()!;
waiter({ value: undefined as unknown, done: true });
}
}
async *[Symbol.asyncIterator](): AsyncIterator<T> {
while (true) {
if (this.queue.length > 0) {
yield this.queue.shift()!;
} else if (this.done) {
return;
} else {
const result = await new Promise<IteratorResult<T>>((resolve) => {
this.waiting.push(resolve);
});
if (result.done) {
return;
}
yield result.value;
}
}
}
result(): Promise<R> {
return this.finalResultPromise;
}
}
/** Assistant-message event stream that resolves on done/error terminal events. */
export class AssistantMessageEventStream
extends EventStream<AssistantMessageEvent, AssistantMessage>
implements AssistantMessageEventStreamContract
{
constructor() {
super(
(event) => event.type === "done" || event.type === "error",
(event) => {
if (event.type === "done") {
return event.message;
} else if (event.type === "error") {
return event.error;
}
throw new Error("Unexpected event type for final result");
},
);
}
}
/** Creates an assistant-message stream for provider and plugin adapters. */
export function createAssistantMessageEventStream(): AssistantMessageEventStream {
return new AssistantMessageEventStream();
}

View File

@@ -0,0 +1,193 @@
// LLM Core tests cover validation behavior.
import { describe, expect, it } from "vitest";
import type { Tool } from "./types.js";
import { validateToolArguments } from "./validation.js";
const decimalTool = {
name: "decimal-tool",
description: "test tool",
parameters: {
type: "object",
properties: {
amount: { type: "number" },
count: { type: "integer" },
},
required: ["amount", "count"],
additionalProperties: false,
},
} as Tool;
describe("validateToolArguments", () => {
it("coerces strict decimal numeric strings for plain JSON schemas", () => {
expect(
validateToolArguments(decimalTool, {
type: "toolCall",
id: "call-1",
name: "decimal-tool",
arguments: { amount: "1e3", count: "+3" },
}),
).toEqual({ amount: 1000, count: 3 });
});
it("rejects non-decimal numeric strings for plain JSON schemas", () => {
expect(() =>
validateToolArguments(decimalTool, {
type: "toolCall",
id: "call-1",
name: "decimal-tool",
arguments: { amount: "0x10", count: "0b10" },
}),
).toThrow(/Validation failed for tool "decimal-tool"/);
});
it("preserves null in anyOf [{type: string}, {type: null}] without coercing to empty string (#96716)", () => {
const tool = {
name: "nullable-tool",
description: "test tool",
parameters: {
type: "object",
properties: {
insight_id: { anyOf: [{ type: "string" }, { type: "null" }] },
cluster_name: { type: "string" },
},
required: ["cluster_name"],
additionalProperties: false,
},
} as Tool;
expect(
validateToolArguments(tool, {
type: "toolCall",
id: "call-1",
name: "nullable-tool",
arguments: { insight_id: null, cluster_name: "testenv" },
}),
).toEqual({ insight_id: null, cluster_name: "testenv" });
});
});
const arrayTool = {
name: "array-tool",
description: "test tool with array param",
parameters: {
type: "object",
properties: {
tags: { type: "array", items: { type: "string" } },
},
required: ["tags"],
additionalProperties: false,
},
} as Tool;
const objectTool = {
name: "object-tool",
description: "test tool with object param",
parameters: {
type: "object",
properties: {
config: {
type: "object",
properties: {
enabled: { type: "boolean" },
retries: { type: "number" },
},
},
},
required: ["config"],
additionalProperties: false,
},
} as Tool;
describe("validateToolArguments — stringified JSON coercion", () => {
it("coerces stringified JSON array to array for plain JSON schemas", () => {
expect(
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-2",
name: "array-tool",
arguments: { tags: '["test","debug"]' },
}),
).toEqual({ tags: ["test", "debug"] });
});
it("coerces stringified JSON object to object for plain JSON schemas", () => {
expect(
validateToolArguments(objectTool, {
type: "toolCall",
id: "call-3",
name: "object-tool",
arguments: { config: '{"enabled":true,"retries":3}' },
}),
).toEqual({ config: { enabled: true, retries: 3 } });
});
it("passes through valid arrays unchanged", () => {
expect(
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-4",
name: "array-tool",
arguments: { tags: ["already", "array"] },
}),
).toEqual({ tags: ["already", "array"] });
});
it("passes through valid objects unchanged", () => {
expect(
validateToolArguments(objectTool, {
type: "toolCall",
id: "call-5",
name: "object-tool",
arguments: { config: { enabled: false, retries: 1 } },
}),
).toEqual({ config: { enabled: false, retries: 1 } });
});
it("rejects invalid JSON string for array param", () => {
expect(() =>
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-6",
name: "array-tool",
arguments: { tags: "not-json" },
}),
).toThrow(/Validation failed for tool "array-tool"/);
});
it("rejects JSON string that is wrong type for array param", () => {
expect(() =>
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-7",
name: "array-tool",
arguments: { tags: '{"not":"array"}' },
}),
).toThrow(/Validation failed for tool "array-tool"/);
});
it("skips JSON coercion for oversized array string", () => {
const hugeArray = JSON.stringify(Array.from({ length: 100_000 }, (_, i) => i));
expect(hugeArray.length).toBeGreaterThan(64 * 1024);
expect(() =>
validateToolArguments(arrayTool, {
type: "toolCall",
id: "call-8",
name: "array-tool",
arguments: { tags: hugeArray },
}),
).toThrow(/Validation failed for tool "array-tool"/);
});
it("skips JSON coercion for oversized object string", () => {
const hugeObj = JSON.stringify({ data: "x".repeat(70_000) });
expect(hugeObj.length).toBeGreaterThan(64 * 1024);
expect(() =>
validateToolArguments(objectTool, {
type: "toolCall",
id: "call-9",
name: "object-tool",
arguments: { config: hugeObj },
}),
).toThrow(/Validation failed for tool "object-tool"/);
});
});

View File

@@ -0,0 +1,381 @@
// LLM Core module implements validation behavior.
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { Value } from "typebox/value";
import type { Tool, ToolCall } from "./types.js";
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
/** Maximum string length accepted for schema-gated JSON coercion. */
const MAX_JSON_COERCE_LENGTH = 64 * 1024;
interface JsonSchemaObject {
type?: string | string[];
properties?: Record<string, JsonSchemaObject>;
items?: JsonSchemaObject | JsonSchemaObject[];
additionalProperties?: boolean | JsonSchemaObject;
allOf?: JsonSchemaObject[];
anyOf?: JsonSchemaObject[];
oneOf?: JsonSchemaObject[];
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isJsonSchemaObject(value: unknown): value is JsonSchemaObject {
return isRecord(value);
}
function hasTypeBoxMetadata(schema: unknown): boolean {
return isRecord(schema) && Object.getOwnPropertySymbols(schema).includes(TYPEBOX_KIND);
}
function getSchemaTypes(schema: JsonSchemaObject): string[] {
if (typeof schema.type === "string") {
return [schema.type];
}
if (Array.isArray(schema.type)) {
return schema.type.filter((type): type is string => typeof type === "string");
}
return [];
}
function matchesJsonType(value: unknown, type: string): boolean {
switch (type) {
case "number":
return typeof value === "number";
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":
return typeof value === "boolean";
case "string":
return typeof value === "string";
case "null":
return value === null;
case "array":
return Array.isArray(value);
case "object":
return isRecord(value) && !Array.isArray(value);
default:
return false;
}
}
function isValidatorSchema(value: unknown): value is Tool["parameters"] {
return isRecord(value);
}
const JSON_NUMBER_TOKEN_RE = /^[+-]?(?:(?:\d+\.?\d*)|(?:\.\d+))(?:e[+-]?\d+)?$/iu;
function parseJsonNumberString(value: string): number | undefined {
const trimmed = value.trim();
if (!trimmed || !JSON_NUMBER_TOKEN_RE.test(trimmed)) {
return undefined;
}
const parsed = Number(trimmed);
return Number.isFinite(parsed) ? parsed : undefined;
}
function parseJsonIntegerString(value: string): number | undefined {
const parsed = parseJsonNumberString(value);
return parsed !== undefined && Number.isSafeInteger(parsed) ? parsed : undefined;
}
function getSubSchemaValidator(schema: JsonSchemaObject): ReturnType<typeof Compile> | undefined {
if (!isValidatorSchema(schema)) {
return undefined;
}
try {
return getValidator(schema);
} catch {
return undefined;
}
}
function coercePrimitiveByType(value: unknown, type: string): unknown {
switch (type) {
case "number": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = parseJsonNumberString(value);
if (parsed !== undefined) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "integer": {
if (value === null) {
return 0;
}
if (typeof value === "string" && value.trim() !== "") {
const parsed = parseJsonIntegerString(value);
if (parsed !== undefined) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "boolean": {
if (value === null) {
return false;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
if (typeof value === "number") {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
}
return value;
}
case "string": {
if (value === null) {
return "";
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return value;
}
case "array": {
if (
typeof value === "string" &&
value.trim() !== "" &&
value.length <= MAX_JSON_COERCE_LENGTH
) {
try {
const parsed: unknown = JSON.parse(value);
if (Array.isArray(parsed)) {
return parsed;
}
} catch {
// Not valid JSON; leave as-is for the validator to reject.
}
}
return value;
}
case "object": {
if (
typeof value === "string" &&
value.trim() !== "" &&
value.length <= MAX_JSON_COERCE_LENGTH
) {
try {
const parsed: unknown = JSON.parse(value);
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
return parsed;
}
} catch {
// Not valid JSON; leave as-is for the validator to reject.
}
}
return value;
}
case "null": {
if (value === "" || value === 0 || value === false) {
return null;
}
return value;
}
default:
return value;
}
}
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
const properties = schema.properties;
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
if (properties) {
for (const [key, propertySchema] of Object.entries(properties)) {
if (key in value) {
value[key] = coerceWithJsonSchema(value[key], propertySchema);
}
}
}
if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) {
for (const [key, propertyValue] of Object.entries(value)) {
if (!definedKeys.has(key)) {
value[key] = coerceWithJsonSchema(propertyValue, schema.additionalProperties);
}
}
}
}
function applySchemaArrayCoercion(value: unknown[], schema: JsonSchemaObject): void {
if (Array.isArray(schema.items)) {
for (let index = 0; index < value.length; index++) {
const itemSchema = schema.items[index];
if (itemSchema) {
value[index] = coerceWithJsonSchema(value[index], itemSchema);
}
}
return;
}
if (isJsonSchemaObject(schema.items)) {
for (let index = 0; index < value.length; index++) {
value[index] = coerceWithJsonSchema(value[index], schema.items);
}
}
}
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
// When value is null, check if any union member accepts null directly
// (type: "null") before falling through to coercion. Without this check,
// anyOf [{type: "string"}, {type: "null"}] coerces null → "" via the
// string branch and never reaches the null branch.
if (value === null) {
for (const schema of schemas) {
const types = getSchemaTypes(schema);
if (types.includes("null")) {
const validator = getSubSchemaValidator(schema);
if (!validator || validator.Check(value)) {
return value;
}
}
}
}
for (const schema of schemas) {
const candidate = structuredClone(value);
const coerced = coerceWithJsonSchema(candidate, schema);
const validator = getSubSchemaValidator(schema);
if (validator?.Check(coerced)) {
return coerced;
}
}
return value;
}
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
let nextValue = value;
if (Array.isArray(schema.allOf)) {
for (const nested of schema.allOf) {
nextValue = coerceWithJsonSchema(nextValue, nested);
}
}
if (Array.isArray(schema.anyOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
}
if (Array.isArray(schema.oneOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
}
const schemaTypes = getSchemaTypes(schema);
const matchesUnionMember =
schemaTypes.length > 1 &&
schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
if (schemaTypes.length > 0 && !matchesUnionMember) {
for (const schemaType of schemaTypes) {
const candidate = coercePrimitiveByType(nextValue, schemaType);
if (candidate !== nextValue) {
nextValue = candidate;
break;
}
}
}
if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) {
applySchemaObjectCoercion(nextValue, schema);
}
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
applySchemaArrayCoercion(nextValue, schema);
}
return nextValue;
}
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
const key = schema as object;
const cached = validatorCache.get(key);
if (cached) {
return cached;
}
const validator = Compile(schema);
validatorCache.set(key, validator);
return validator;
}
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperty = (error.params as { requiredProperties?: string[] })
.requiredProperties?.[0];
if (requiredProperty) {
const basePath = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return basePath ? `${basePath}.${requiredProperty}` : requiredProperty;
}
}
const path = error.instancePath.replace(/^\//, "").replace(/\//g, ".");
return path || "root";
}
/** Finds the target tool and validates/coerces a model-emitted tool call. */
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);
}
/** Validates tool arguments against TypeBox or plain JSON-schema parameters. */
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)) {
// TypeBox Value.Convert is intentionally conservative for plain JSON schemas;
// mirror the provider-facing coercions so model-emitted string numbers validate.
const coerced = coerceWithJsonSchema(args, tool.parameters);
if (coerced !== args) {
if (isRecord(args) && isRecord(coerced)) {
for (const key of Object.keys(args)) {
delete args[key];
}
Object.assign(args, coerced);
} else {
return validator.Check(coerced) ? coerced : args;
}
}
}
if (validator.Check(args)) {
return args;
}
const errors =
validator
.Errors(args)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown validation error";
throw new Error(
`Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`,
);
}

View File

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