fix(typebox): migrate to v1 with extension compat (#3474)

* fix(typebox): migrate to v1 with extension compat

Replace AJV-based validation with TypeBox-native validation, keep legacy extension imports working (including @sinclair/typebox/compiler), and restore coercion for serialized/plain JSON schemas.

This change closes #3112.

* fix(typebox): use canonical imports and harden coercion

Switch first-party code to canonical typebox imports while retaining legacy extension aliases in the loader.

Remove obsolete runtime codegen guards, expand serialized JSON-schema coercion coverage, and update related tests and fixtures.

Fixes #3112.

---------

Co-authored-by: Mario Zechner <badlogicgames@gmail.com>
This commit is contained in:
Armin Ronacher
2026-04-22 19:59:33 +02:00
committed by GitHub
parent a23fab4693
commit 35ff2689ee
82 changed files with 580 additions and 264 deletions

View File

@@ -202,7 +202,7 @@ for (const block of response.content) {
## Tools
Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using AJV. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems.
Tools enable LLMs to interact with external systems. This library uses TypeBox schemas for type-safe tool definitions with automatic validation using TypeBox's built-in validator and value conversion utilities. TypeBox schemas can be serialized and deserialized as plain JSON, making them ideal for distributed systems.
### Defining Tools

View File

@@ -76,9 +76,7 @@
"@aws-sdk/client-bedrock-runtime": "^3.1030.0",
"@google/genai": "^1.40.0",
"@mistralai/mistralai": "^2.2.0",
"@sinclair/typebox": "^0.34.41",
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"typebox": "^1.1.24",
"chalk": "^5.6.2",
"openai": "6.26.0",
"partial-json": "^0.1.7",

View File

@@ -1,5 +1,5 @@
export type { Static, TSchema } from "@sinclair/typebox";
export { Type } from "@sinclair/typebox";
export type { Static, TSchema } from "typebox";
export { Type } from "typebox";
export * from "./api-registry.js";
export * from "./env-api-keys.js";

View File

@@ -20,6 +20,7 @@ import {
type ToolConfiguration,
ToolResultStatus,
} from "@aws-sdk/client-bedrock-runtime";
import type { DocumentType } from "@smithy/types";
import { calculateCost } from "../models.js";
import type {
Api,
@@ -760,7 +761,7 @@ function convertToolConfig(
toolSpec: {
name: tool.name,
description: tool.description,
inputSchema: { json: tool.parameters },
inputSchema: { json: tool.parameters as unknown as DocumentType },
},
}));

View File

@@ -223,7 +223,7 @@ export interface ToolResultMessage<TDetails = any> {
export type Message = UserMessage | AssistantMessage | ToolResultMessage;
import type { TSchema } from "@sinclair/typebox";
import type { TSchema } from "typebox";
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;

View File

@@ -1,4 +1,4 @@
import { type TUnsafe, Type } from "@sinclair/typebox";
import { type TUnsafe, Type } from "typebox";
/**
* Creates a string enum schema compatible with Google's API and other providers

View File

@@ -1,42 +1,270 @@
import AjvModule from "ajv";
import addFormatsModule from "ajv-formats";
// Handle both default and named exports
const Ajv = (AjvModule as any).default || AjvModule;
const addFormats = (addFormatsModule as any).default || addFormatsModule;
import { Compile } from "typebox/compile";
import type { TLocalizedValidationError } from "typebox/error";
import { Value } from "typebox/value";
import type { Tool, ToolCall } from "../types.js";
// Detect if we're in a browser extension environment with strict CSP
// Chrome extensions with Manifest V3 don't allow eval/Function constructor
const isBrowserExtension = typeof globalThis !== "undefined" && (globalThis as any).chrome?.runtime?.id !== undefined;
const validatorCache = new WeakMap<object, ReturnType<typeof Compile>>();
const TYPEBOX_KIND = Symbol.for("TypeBox.Kind");
function canUseRuntimeCodegen(): boolean {
if (isBrowserExtension) {
return false;
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 [];
}
try {
new Function("return true;");
return true;
} catch {
return false;
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;
}
}
// Create a singleton AJV instance with formats only when runtime code generation is available.
let ajv: any = null;
if (canUseRuntimeCodegen()) {
try {
ajv = new Ajv({
allErrors: true,
strict: false,
coerceTypes: true,
});
addFormats(ajv);
} catch (_e) {
console.warn("AJV validation disabled due to CSP restrictions");
function isValidatorSchema(value: unknown): value is Tool["parameters"] {
return isRecord(value);
}
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 = Number(value);
if (Number.isFinite(parsed)) {
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 = Number(value);
if (Number.isInteger(parsed)) {
return parsed;
}
}
if (typeof value === "boolean") {
return value ? 1 : 0;
}
return value;
}
case "boolean": {
if (value === null) {
return false;
}
if (typeof value === "string") {
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
}
if (typeof value === "number") {
if (value === 1) {
return true;
}
if (value === 0) {
return false;
}
}
return value;
}
case "string": {
if (value === null) {
return "";
}
if (typeof value === "number" || typeof value === "boolean") {
return String(value);
}
return value;
}
case "null": {
if (value === "" || value === 0 || value === false) {
return null;
}
return value;
}
default:
return value;
}
}
function applySchemaObjectCoercion(value: Record<string, unknown>, schema: JsonSchemaObject): void {
const properties = schema.properties;
const definedKeys = new Set<string>(properties ? Object.keys(properties) : []);
if (properties) {
for (const [key, propertySchema] of Object.entries(properties)) {
if (!(key in value)) {
continue;
}
value[key] = coerceWithJsonSchema(value[key], propertySchema);
}
}
if (schema.additionalProperties && isJsonSchemaObject(schema.additionalProperties)) {
for (const [key, propertyValue] of Object.entries(value)) {
if (definedKeys.has(key)) {
continue;
}
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) {
continue;
}
value[index] = coerceWithJsonSchema(value[index], itemSchema);
}
return;
}
if (isJsonSchemaObject(schema.items)) {
for (let index = 0; index < value.length; index++) {
value[index] = coerceWithJsonSchema(value[index], schema.items);
}
}
}
function coerceWithUnionSchema(value: unknown, schemas: JsonSchemaObject[]): unknown {
for (const schema of schemas) {
const candidate = structuredClone(value);
const coerced = coerceWithJsonSchema(candidate, schema);
const validator = getSubSchemaValidator(schema);
if (validator?.Check(coerced)) {
return coerced;
}
}
return value;
}
function coerceWithJsonSchema(value: unknown, schema: JsonSchemaObject): unknown {
let nextValue = value;
if (Array.isArray(schema.allOf)) {
for (const nested of schema.allOf) {
nextValue = coerceWithJsonSchema(nextValue, nested);
}
}
if (Array.isArray(schema.anyOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.anyOf);
}
if (Array.isArray(schema.oneOf)) {
nextValue = coerceWithUnionSchema(nextValue, schema.oneOf);
}
const schemaTypes = getSchemaTypes(schema);
const matchesUnionMember =
schemaTypes.length > 1 && schemaTypes.some((schemaType) => matchesJsonType(nextValue, schemaType));
if (schemaTypes.length > 0 && !matchesUnionMember) {
for (const schemaType of schemaTypes) {
const candidate = coercePrimitiveByType(nextValue, schemaType);
if (candidate !== nextValue) {
nextValue = candidate;
break;
}
}
}
if (schemaTypes.includes("object") && isRecord(nextValue) && !Array.isArray(nextValue)) {
applySchemaObjectCoercion(nextValue, schema);
}
if (schemaTypes.includes("array") && Array.isArray(nextValue)) {
applySchemaArrayCoercion(nextValue, schema);
}
return nextValue;
}
function getValidator(schema: Tool["parameters"]): ReturnType<typeof Compile> {
const key = schema as object;
const cached = validatorCache.get(key);
if (cached) {
return cached;
}
const validator = Compile(schema);
validatorCache.set(key, validator);
return validator;
}
function formatValidationPath(error: TLocalizedValidationError): string {
if (error.keyword === "required") {
const requiredProperties = (error.params as { requiredProperties?: string[] }).requiredProperties;
const requiredProperty = 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";
}
/**
@@ -62,29 +290,32 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): any {
* @throws Error with formatted message if validation fails
*/
export function validateToolArguments(tool: Tool, toolCall: ToolCall): any {
// Skip validation in environments where runtime code generation is unavailable.
if (!ajv || !canUseRuntimeCodegen()) {
return toolCall.arguments;
const args = structuredClone(toolCall.arguments);
Value.Convert(tool.parameters, args);
const validator = getValidator(tool.parameters);
if (!hasTypeBoxMetadata(tool.parameters) && isJsonSchemaObject(tool.parameters)) {
const coerced = coerceWithJsonSchema(args, tool.parameters);
if (coerced !== args) {
if (isRecord(args) && isRecord(coerced)) {
for (const key of Object.keys(args)) {
delete args[key];
}
Object.assign(args, coerced);
} else {
return validator.Check(coerced) ? coerced : args;
}
}
}
// Compile the schema.
const validate = ajv.compile(tool.parameters);
// Clone arguments so AJV can safely mutate for type coercion
const args = structuredClone(toolCall.arguments);
// Validate the arguments (AJV mutates args in-place for type coercion)
if (validate(args)) {
if (validator.Check(args)) {
return args;
}
// Format validation errors nicely
const errors =
validate.errors
?.map((err: any) => {
const path = err.instancePath ? err.instancePath.substring(1) : err.params.missingProperty || "root";
return ` - ${path}: ${err.message}`;
})
validator
.Errors(args)
.map((error) => ` - ${formatValidationPath(error)}: ${error.message}`)
.join("\n") || "Unknown validation error";
const errorMessage = `Validation failed for tool "${toolCall.name}":\n${errors}\n\nReceived arguments:\n${JSON.stringify(toolCall.arguments, null, 2)}`;

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { stream } from "../src/stream.js";

View File

@@ -22,8 +22,8 @@
* Fixtures are generated fresh on each run.
*/
import { Type } from "@sinclair/typebox";
import { writeFileSync } from "fs";
import { Type } from "typebox";
import { beforeAll, describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { completeSimple, getEnvApiKey } from "../src/stream.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { afterEach, describe, expect, it, vi } from "vitest";
import { streamGoogleGeminiCli } from "../src/providers/google-gemini-cli.js";
import type { Context, Model, ToolCall } from "../src/types.js";

View File

@@ -1,6 +1,6 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { Api, Context, Model, Tool, ToolResultMessage } from "../src/index.js";
import { complete, getModel } from "../src/index.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getEnvApiKey } from "../src/env-api-keys.js";
import { getModel } from "../src/models.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete } from "../src/stream.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getModel } from "../src/models.js";
import { streamOpenAICompletions } from "../src/providers/openai-completions.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getModel } from "../src/models.js";
import { streamSimple } from "../src/stream.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete, getEnvApiKey } from "../src/stream.js";

View File

@@ -1,8 +1,8 @@
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { Type } from "@sinclair/typebox";
import type { ResponseFunctionCallOutputItemList } from "openai/resources/responses/responses.js";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { Api, Context, Model, StreamOptions, Tool, ToolResultMessage } from "../src/index.js";
import { complete, getModel } from "../src/index.js";

View File

@@ -1,7 +1,7 @@
import { Type } from "@sinclair/typebox";
import { type ChildProcess, execSync, spawn } from "child_process";
import { readFileSync } from "fs";
import { dirname, join } from "path";
import { Type } from "typebox";
import { fileURLToPath } from "url";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";

View File

@@ -10,7 +10,7 @@
* Regression test for: https://github.com/badlogic/pi-mono/issues/1022
*/
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { completeSimple, getEnvApiKey } from "../src/stream.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete } from "../src/stream.js";

View File

@@ -1,4 +1,4 @@
import { Type } from "@sinclair/typebox";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import { getModel } from "../src/models.js";
import { complete } from "../src/stream.js";

View File

@@ -1,17 +1,41 @@
import { Type } from "@sinclair/typebox";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { ToolCall } from "../src/types.js";
import { Type } from "typebox";
import { describe, expect, it } from "vitest";
import type { Tool, ToolCall } from "../src/types.js";
import { validateToolArguments } from "../src/utils/validation.js";
afterEach(() => {
vi.restoreAllMocks();
});
function createToolCallWithPlainSchema(
schema: Tool["parameters"],
value: unknown,
): {
tool: Tool;
toolCall: ToolCall;
} {
const tool: Tool = {
name: "echo",
description: "Echo tool",
parameters: {
type: "object",
properties: {
value: schema,
},
required: ["value"],
} as Tool["parameters"],
};
const toolCall: ToolCall = {
type: "toolCall",
id: "tool-1",
name: "echo",
arguments: { value },
};
return { tool, toolCall };
}
describe("validateToolArguments", () => {
it("falls back to raw arguments without writing to stderr when runtime code generation is blocked", () => {
it("still validates when Function constructor is unavailable", () => {
const originalFunction = globalThis.Function;
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const tool = {
const tool: Tool = {
name: "echo",
description: "Echo tool",
parameters: Type.Object({
@@ -30,10 +54,63 @@ describe("validateToolArguments", () => {
}) as unknown as FunctionConstructor;
try {
expect(validateToolArguments(tool, toolCall)).toEqual(toolCall.arguments);
expect(errorSpy).not.toHaveBeenCalled();
expect(validateToolArguments(tool, toolCall)).toEqual({ count: 42 });
} finally {
globalThis.Function = originalFunction;
}
});
it("coerces serialized plain JSON schemas with AJV-compatible primitive rules", () => {
const passingCases: Array<{
schema: Tool["parameters"];
input: unknown;
expected: unknown;
}> = [
{ schema: { type: "number" } as Tool["parameters"], input: "42", expected: 42 },
{ schema: { type: "number" } as Tool["parameters"], input: true, expected: 1 },
{ schema: { type: "number" } as Tool["parameters"], input: null, expected: 0 },
{ schema: { type: "integer" } as Tool["parameters"], input: "42", expected: 42 },
{ schema: { type: "boolean" } as Tool["parameters"], input: "true", expected: true },
{ schema: { type: "boolean" } as Tool["parameters"], input: "false", expected: false },
{ schema: { type: "boolean" } as Tool["parameters"], input: 1, expected: true },
{ schema: { type: "boolean" } as Tool["parameters"], input: 0, expected: false },
{ schema: { type: "string" } as Tool["parameters"], input: null, expected: "" },
{ schema: { type: "string" } as Tool["parameters"], input: true, expected: "true" },
{ schema: { type: "null" } as Tool["parameters"], input: "", expected: null },
{ schema: { type: "null" } as Tool["parameters"], input: 0, expected: null },
{ schema: { type: "null" } as Tool["parameters"], input: false, expected: null },
{
schema: { type: ["number", "string"] } as Tool["parameters"],
input: "1",
expected: "1",
},
{
schema: { type: ["boolean", "number"] } as Tool["parameters"],
input: "1",
expected: 1,
},
];
for (const testCase of passingCases) {
const { tool, toolCall } = createToolCallWithPlainSchema(testCase.schema, testCase.input);
expect(validateToolArguments(tool, toolCall)).toEqual({ value: testCase.expected });
}
});
it("rejects invalid coercions for serialized plain JSON schemas", () => {
const failingCases: Array<{
schema: Tool["parameters"];
input: unknown;
}> = [
{ schema: { type: "boolean" } as Tool["parameters"], input: "1" },
{ schema: { type: "boolean" } as Tool["parameters"], input: "0" },
{ schema: { type: "null" } as Tool["parameters"], input: "null" },
{ schema: { type: "integer" } as Tool["parameters"], input: "42.1" },
];
for (const testCase of failingCases) {
const { tool, toolCall } = createToolCallWithPlainSchema(testCase.schema, testCase.input);
expect(() => validateToolArguments(tool, toolCall)).toThrow("Validation failed");
}
});
});