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
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:
41
packages/llm-core/package.json
Normal file
41
packages/llm-core/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
packages/llm-core/src/index.ts
Normal file
6
packages/llm-core/src/index.ts
Normal 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";
|
||||
91
packages/llm-core/src/model-contracts/anthropic.ts
Normal file
91
packages/llm-core/src/model-contracts/anthropic.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
659
packages/llm-core/src/types.ts
Normal file
659
packages/llm-core/src/types.ts
Normal 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;
|
||||
56
packages/llm-core/src/utils/diagnostics.ts
Normal file
56
packages/llm-core/src/utils/diagnostics.ts
Normal 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];
|
||||
}
|
||||
101
packages/llm-core/src/utils/event-stream.ts
Normal file
101
packages/llm-core/src/utils/event-stream.ts
Normal 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();
|
||||
}
|
||||
193
packages/llm-core/src/validation.test.ts
Normal file
193
packages/llm-core/src/validation.test.ts
Normal 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"/);
|
||||
});
|
||||
});
|
||||
381
packages/llm-core/src/validation.ts
Normal file
381
packages/llm-core/src/validation.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
8
packages/llm-core/tsconfig.json
Normal file
8
packages/llm-core/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist"
|
||||
},
|
||||
"include": ["src/**/*"]
|
||||
}
|
||||
Reference in New Issue
Block a user