fix(ai): corrected AI tool handling via JSON-schema validation flow

- Replaced fromTypeBox conversion with a JSON-schema validator flow in ai tool handling and execution paths.
- Added recursive schema validation and expanded TypeBox checks for refs, enums, uniqueItems, and constraint keywords.
- Sanitized Azure/CCA tool schemas by dropping unsupported fields and rewriting oneOf tool branches as anyOf.
- Tightened argument and model-config validation, preserving unknown tool fields and adding apiKey plus compatibility flags.
This commit is contained in:
can1357
2026-05-15 15:16:50 +02:00
parent 2867e1f4e3
commit 45fe4df39e
25 changed files with 1025 additions and 232 deletions
+7
View File
@@ -1,6 +1,7 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed TypeBox root exports (`Type`, `Static`, and `TSchema`) from the package entrypoint, so callers importing those symbols from `@oh-my-pi/pi-ai` must migrate to `zod` or `@oh-my-pi/pi-ai/types`
@@ -19,11 +20,17 @@
### Changed
- Changed Azure OpenAI Responses tool schema conversion to sanitize tool parameter schemas and rewrite `oneOf` branches as `anyOf` so tool calls remain compatible with Azure's schema expectations
- Changed `Static<S>` to extract a schema object’s `static` type when present, improving inferred tool argument types for non-Zod parameter definitions
- Changed `Static` typing behavior so it now infers argument types from Zod schemas and defaults to `unknown` for non-Zod JSON Schema parameter definitions
- Restored the default steady-state stream idle timeout to 120s (regressed in 15.0.0). 30s was too aggressive for reasoning models, slow proxies, and tool-call planning gaps, surfacing as repeated `Provider stream stalled while waiting for the next event` errors. Existing `PI_STREAM_IDLE_TIMEOUT_MS` / `PI_OPENAI_STREAM_IDLE_TIMEOUT_MS` overrides are unchanged.
### Fixed
- Preserved top-level unknown fields in validated tool-call arguments so extra root properties are retained after schema coercion
- Fixed coercion for Zod `record` fields by parsing JSON-stringified record arguments into objects
- Validated legacy draft-07 JSON Schema tool parameters directly instead of converting through Zod, improving support for features like `$ref`, `definitions`, `nullable`, and `uniqueItems`
- Fixed Cloud Code Assist schema preparation to strip unsupported `propertyNames` and fall back to a minimal tool schema when schema meta-validation detects malformed keywords
- Fixed OpenAI Completions streaming to avoid treating non-output chunks (including role-only preambles) as progress events so idle-timeout watchdog behavior no longer hangs on no-op streamed chunks
- Fixed Cloud Code Assist schema compatibility checks by replacing strict AJV meta-schema validation with structural JSON Schema validation to avoid rejecting structurally valid tool schemas
- Fixed lazy built-in provider streams (`anthropic-messages`, `bedrock-converse-stream`, `cursor-agent`, `google-*`, `ollama-chat`, `openai-*`) prematurely aborting slow first-token responses with `Provider stream stalled while waiting for the next event`. The lazy-stream watchdog wrapper was treating the synthetic `start` event (yielded immediately by every provider before the model emits any tokens) as the first real item, which caused the watchdog to drop from `firstItemTimeoutMs` (100s) to `idleTimeoutMs` (30s) before the upstream model had produced anything. The shared `iterateWithIdleTimeout` now keeps `awaitingFirstItem` true until a real progress item arrives, and the lazy-stream wrapper marks `start` as a non-progress keepalive ([#1073](https://github.com/can1357/oh-my-pi/pull/1073) regression).
@@ -26,7 +26,7 @@ import {
getStreamFirstEventTimeoutMs,
iterateWithIdleTimeout,
} from "../utils/idle-iterator";
import { toolWireSchema } from "../utils/schema/wire";
import { sanitizeSchemaForOpenAIResponses, toolWireSchema } from "../utils/schema";
import { wrapFetchForSseDebug } from "../utils/sse-debug";
import { mapToOpenAIResponsesToolChoice } from "../utils/tool-choice";
import { normalizeOpenAIResponsesPromptCacheKey, supportsDeveloperRole } from "./openai-responses";
@@ -331,7 +331,7 @@ function convertTools(tools: Tool[]): OpenAITool[] {
type: "function",
name: tool.name,
description: tool.description || "",
parameters: toolWireSchema(tool),
parameters: sanitizeSchemaForOpenAIResponses(toolWireSchema(tool)),
strict: false,
}));
}
+14 -14
View File
@@ -6,20 +6,20 @@ import type {
ResponseInput,
} from "openai/resources/responses/responses";
import { getEnvApiKey } from "../stream";
import {
type AssistantMessage,
type CacheRetention,
type Context,
type FetchImpl,
type MessageAttribution,
type Model,
type OpenAICompat,
type ProviderSessionState,
type ServiceTier,
type StreamFunction,
type StreamOptions,
type Tool,
type ToolChoice,
import type {
AssistantMessage,
CacheRetention,
Context,
FetchImpl,
MessageAttribution,
Model,
OpenAICompat,
ProviderSessionState,
ServiceTier,
StreamFunction,
StreamOptions,
Tool,
ToolChoice,
} from "../types";
import {
createOpenAIResponsesHistoryPayload,
+1 -1
View File
@@ -552,7 +552,7 @@ export type TJsonSchema = Record<string, unknown>;
export type TSchema = ZodType | TJsonSchema;
/** Resolve parameter types for tool execution / handlers. */
export type Static<S> = S extends ZodType ? z.infer<S> : unknown;
export type Static<S> = S extends ZodType ? z.infer<S> : S extends { static: infer T } ? T : unknown;
export interface Tool<TParameters extends TSchema = TSchema> {
name: string;
+10 -2
View File
@@ -21,6 +21,7 @@ export const UNSUPPORTED_SCHEMA_FIELDS = new Set([
"unevaluatedItems",
"patternProperties",
"additionalProperties",
"propertyNames",
"minItems",
"maxItems",
"minLength",
@@ -142,6 +143,13 @@ export const COMBINATOR_KEYS = ["anyOf", "allOf", "oneOf"] as const;
* Cloud Code Assist Claude unsupported schema fields.
* Much smaller than UNSUPPORTED_SCHEMA_FIELDS (Google) because CCA supports
* validation keywords like additionalProperties, minLength, pattern, etc.
* Only meta/reference keywords that CCA cannot resolve are stripped.
* Meta/reference keywords plus object-key validators that CCA cannot resolve are stripped.
*/
export const CCA_UNSUPPORTED_SCHEMA_FIELDS = new Set(["$schema", "$ref", "$defs", "$dynamicRef", "$dynamicAnchor"]);
export const CCA_UNSUPPORTED_SCHEMA_FIELDS = new Set([
"$schema",
"$ref",
"$defs",
"$dynamicRef",
"$dynamicAnchor",
"propertyNames",
]);
@@ -1,34 +0,0 @@
/**
* Convert a plain JSON Schema object (OpenAPI / draft-07 style) into a Zod schema.
*
* Internal validation runs against Zod; parameters may be authored as Zod directly
* or supplied as JSON Schema (legacy extension payloads). This helper turns JSON
* Schema into Zod once at validation boundary and caches by object identity.
*
* Delegates to `z.fromJSONSchema` — no dedicated TypeBox dependency at runtime.
*/
import { type ZodType, z } from "zod/v4";
/** WeakMap cache so repeated registrations of the same JSON Schema object reuse the Zod result. */
const cache = new WeakMap<object, ZodType>();
/**
* Convert JSON Schema (plain object) to a Zod schema. Returns a cached
* value when called repeatedly with the same source object.
*/
export function fromTypeBox(schema: Record<string, unknown> | unknown): ZodType {
if (typeof schema === "object" && schema !== null) {
const cached = cache.get(schema as object);
if (cached) return cached;
// Pass the schema through Zod's JSON Schema importer. We pass through
// any non-standard keys; Zod silently ignores unknown keywords.
const zodSchema = z.fromJSONSchema(schema as Parameters<typeof z.fromJSONSchema>[0]) as ZodType;
cache.set(schema as object, zodSchema);
return zodSchema;
}
// Defensive fallback — the validator only ever calls this with object
// schemas, but if someone hands us a scalar we return an `unknown` Zod
// schema so callers don't crash mid-pipeline.
return z.unknown() as unknown as ZodType;
}
+1 -1
View File
@@ -3,7 +3,7 @@ export * from "./compatibility";
export * from "./dereference";
export * from "./equality";
export * from "./fields";
export * from "./from-typebox";
export * from "./json-schema-validator";
export * from "./meta-validator";
export * from "./normalize-cca";
export * from "./sanitize-google";
@@ -0,0 +1,395 @@
import { areJsonValuesEqual } from "./equality";
export interface JsonSchemaValidationIssue {
path: PropertyKey[];
message: string;
expectedTypes?: string[];
keyword?: string;
}
export interface JsonSchemaValidationResult {
success: boolean;
issues: JsonSchemaValidationIssue[];
}
interface ValidationContext {
root: unknown;
seenRefs: Set<string>;
}
function isJsonObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function pushIssue(
issues: JsonSchemaValidationIssue[],
path: readonly PropertyKey[],
message: string,
options: { expectedTypes?: string[]; keyword?: string } = {},
): void {
issues.push({ path: [...path], message, ...options });
}
function typeOfJsonValue(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
if (typeof value === "number" && Number.isInteger(value)) return "integer";
return typeof value;
}
function matchesJsonSchemaType(value: unknown, type: string): boolean {
switch (type) {
case "string":
return typeof value === "string";
case "number":
return typeof value === "number" && Number.isFinite(value);
case "integer":
return typeof value === "number" && Number.isInteger(value);
case "boolean":
return typeof value === "boolean";
case "object":
return isJsonObject(value);
case "array":
return Array.isArray(value);
case "null":
return value === null;
default:
return false;
}
}
function schemaTypes(schema: Record<string, unknown>): string[] {
const raw = schema.type;
const types =
typeof raw === "string"
? [raw]
: Array.isArray(raw)
? raw.filter((entry): entry is string => typeof entry === "string")
: [];
if (schema.nullable === true && !types.includes("null")) {
return [...types, "null"];
}
return types;
}
function decodePointerToken(token: string): string {
return token.replace(/~1/g, "/").replace(/~0/g, "~");
}
function resolveLocalRef(root: unknown, ref: string): unknown | undefined {
if (ref === "#") return root;
if (!ref.startsWith("#/")) return undefined;
let current: unknown = root;
for (const rawToken of ref.slice(2).split("/")) {
const token = decodePointerToken(rawToken);
if (!isJsonObject(current) && !Array.isArray(current)) return undefined;
current = (current as Record<string, unknown>)[token];
}
return current;
}
function isRequiredSet(value: unknown): value is string[] {
return Array.isArray(value) && value.every(entry => typeof entry === "string");
}
function validateSchemaNode(
schema: unknown,
value: unknown,
path: readonly PropertyKey[],
ctx: ValidationContext,
issues: JsonSchemaValidationIssue[],
): boolean {
if (schema === true) return true;
if (schema === false) {
pushIssue(issues, path, "must not match false schema", { keyword: "false" });
return false;
}
if (!isJsonObject(schema)) {
pushIssue(issues, path, "schema must be an object or boolean", { keyword: "schema" });
return false;
}
const ref = schema.$ref;
if (typeof ref === "string") {
if (ctx.seenRefs.has(ref)) return true;
const resolved = resolveLocalRef(ctx.root, ref);
if (resolved === undefined) {
pushIssue(issues, path, `unresolved reference ${ref}`, { keyword: "$ref" });
return false;
}
ctx.seenRefs.add(ref);
const ok = validateSchemaNode(resolved, value, path, ctx, issues);
ctx.seenRefs.delete(ref);
return ok;
}
if (value === null && schema.nullable === true) return true;
let valid = true;
const types = schemaTypes(schema);
if (types.length > 0 && !types.some(type => matchesJsonSchemaType(value, type))) {
pushIssue(issues, path, `expected ${types.join(" or ")}, received ${typeOfJsonValue(value)}`, {
keyword: "type",
expectedTypes: types,
});
return false;
}
if ("const" in schema && !areJsonValuesEqual(value, schema.const)) {
pushIssue(issues, path, "must equal const value", { keyword: "const" });
valid = false;
}
if (Array.isArray(schema.enum) && !schema.enum.some(entry => areJsonValuesEqual(entry, value))) {
pushIssue(issues, path, "must be one of the allowed enum values", { keyword: "enum" });
valid = false;
}
for (const keyword of ["anyOf", "oneOf", "allOf"] as const) {
const branches = schema[keyword];
if (!Array.isArray(branches)) continue;
if (keyword === "allOf") {
for (const branch of branches) {
valid = validateSchemaNode(branch, value, path, ctx, issues) && valid;
}
continue;
}
let matches = 0;
let firstIssues: JsonSchemaValidationIssue[] | undefined;
for (const branch of branches) {
const branchIssues: JsonSchemaValidationIssue[] = [];
if (validateSchemaNode(branch, value, path, ctx, branchIssues)) {
matches += 1;
} else if (!firstIssues) {
firstIssues = branchIssues;
}
}
const branchValid = keyword === "anyOf" ? matches > 0 : matches === 1;
if (!branchValid) {
if (matches === 0 && firstIssues && firstIssues.length > 0) {
issues.push(...firstIssues);
} else {
pushIssue(
issues,
path,
keyword === "anyOf" ? "must match at least one schema" : "must match exactly one schema",
{
keyword,
},
);
}
valid = false;
}
}
if ("not" in schema) {
const notIssues: JsonSchemaValidationIssue[] = [];
if (validateSchemaNode(schema.not, value, path, ctx, notIssues)) {
pushIssue(issues, path, "must not match excluded schema", { keyword: "not" });
valid = false;
}
}
if (isJsonObject(value)) {
valid = validateObjectKeywords(schema, value, path, ctx, issues) && valid;
}
if (Array.isArray(value)) {
valid = validateArrayKeywords(schema, value, path, ctx, issues) && valid;
}
if (typeof value === "string") {
valid = validateStringKeywords(schema, value, path, issues) && valid;
}
if (typeof value === "number" && Number.isFinite(value)) {
valid = validateNumberKeywords(schema, value, path, issues) && valid;
}
return valid;
}
function validateObjectKeywords(
schema: Record<string, unknown>,
value: Record<string, unknown>,
path: readonly PropertyKey[],
ctx: ValidationContext,
issues: JsonSchemaValidationIssue[],
): boolean {
let valid = true;
const properties = isJsonObject(schema.properties) ? schema.properties : {};
if (isRequiredSet(schema.required)) {
for (const key of schema.required) {
if (!(key in value)) {
pushIssue(issues, [...path, key], "is required", { keyword: "required" });
valid = false;
}
}
}
for (const [key, propertySchema] of Object.entries(properties)) {
if (!(key in value)) continue;
valid = validateSchemaNode(propertySchema, value[key], [...path, key], ctx, issues) && valid;
}
if (schema.propertyNames !== undefined) {
for (const key of Object.keys(value)) {
valid = validateSchemaNode(schema.propertyNames, key, [...path, key], ctx, issues) && valid;
}
}
const known = new Set(Object.keys(properties));
const additional = schema.additionalProperties;
if (additional === false) {
for (const key of Object.keys(value)) {
if (known.has(key)) continue;
pushIssue(issues, [...path, key], "must not be present", { keyword: "additionalProperties" });
valid = false;
}
} else if (additional !== undefined && additional !== true) {
for (const [key, entry] of Object.entries(value)) {
if (known.has(key)) continue;
valid = validateSchemaNode(additional, entry, [...path, key], ctx, issues) && valid;
}
}
if (typeof schema.minProperties === "number" && Object.keys(value).length < schema.minProperties) {
pushIssue(issues, path, `must have at least ${schema.minProperties} properties`, { keyword: "minProperties" });
valid = false;
}
if (typeof schema.maxProperties === "number" && Object.keys(value).length > schema.maxProperties) {
pushIssue(issues, path, `must have at most ${schema.maxProperties} properties`, { keyword: "maxProperties" });
valid = false;
}
return valid;
}
function validateArrayKeywords(
schema: Record<string, unknown>,
value: unknown[],
path: readonly PropertyKey[],
ctx: ValidationContext,
issues: JsonSchemaValidationIssue[],
): boolean {
let valid = true;
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
pushIssue(issues, path, `must have at least ${schema.minItems} items`, { keyword: "minItems" });
valid = false;
}
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
pushIssue(issues, path, `must have at most ${schema.maxItems} items`, { keyword: "maxItems" });
valid = false;
}
if (schema.uniqueItems === true) {
for (let i = 0; i < value.length; i += 1) {
for (let j = i + 1; j < value.length; j += 1) {
if (!areJsonValuesEqual(value[i], value[j])) continue;
pushIssue(issues, [...path, j], "must be unique", { keyword: "uniqueItems" });
valid = false;
}
}
}
const items = schema.items;
if (Array.isArray(items)) {
const limit = Math.min(items.length, value.length);
for (let i = 0; i < limit; i += 1) {
valid = validateSchemaNode(items[i], value[i], [...path, i], ctx, issues) && valid;
}
if (schema.additionalItems === false && value.length > items.length) {
for (let i = items.length; i < value.length; i += 1) {
pushIssue(issues, [...path, i], "must not be present", { keyword: "additionalItems" });
valid = false;
}
} else if (schema.additionalItems !== undefined && schema.additionalItems !== true) {
for (let i = items.length; i < value.length; i += 1) {
valid = validateSchemaNode(schema.additionalItems, value[i], [...path, i], ctx, issues) && valid;
}
}
} else if (items !== undefined) {
for (let i = 0; i < value.length; i += 1) {
valid = validateSchemaNode(items, value[i], [...path, i], ctx, issues) && valid;
}
}
return valid;
}
function validateStringKeywords(
schema: Record<string, unknown>,
value: string,
path: readonly PropertyKey[],
issues: JsonSchemaValidationIssue[],
): boolean {
let valid = true;
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
pushIssue(issues, path, `must be at least ${schema.minLength} characters`, { keyword: "minLength" });
valid = false;
}
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
pushIssue(issues, path, `must be at most ${schema.maxLength} characters`, { keyword: "maxLength" });
valid = false;
}
if (typeof schema.pattern === "string") {
try {
if (!new RegExp(schema.pattern).test(value)) {
pushIssue(issues, path, "must match pattern", { keyword: "pattern" });
valid = false;
}
} catch {
pushIssue(issues, path, "schema pattern is invalid", { keyword: "pattern" });
valid = false;
}
}
return valid;
}
function validateNumberKeywords(
schema: Record<string, unknown>,
value: number,
path: readonly PropertyKey[],
issues: JsonSchemaValidationIssue[],
): boolean {
let valid = true;
if (typeof schema.minimum === "number" && value < schema.minimum) {
pushIssue(issues, path, `must be >= ${schema.minimum}`, { keyword: "minimum" });
valid = false;
}
if (typeof schema.maximum === "number" && value > schema.maximum) {
pushIssue(issues, path, `must be <= ${schema.maximum}`, { keyword: "maximum" });
valid = false;
}
if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) {
pushIssue(issues, path, `must be > ${schema.exclusiveMinimum}`, { keyword: "exclusiveMinimum" });
valid = false;
}
if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) {
pushIssue(issues, path, `must be < ${schema.exclusiveMaximum}`, { keyword: "exclusiveMaximum" });
valid = false;
}
if (schema.exclusiveMinimum === true && typeof schema.minimum === "number" && value <= schema.minimum) {
pushIssue(issues, path, `must be > ${schema.minimum}`, { keyword: "exclusiveMinimum" });
valid = false;
}
if (schema.exclusiveMaximum === true && typeof schema.maximum === "number" && value >= schema.maximum) {
pushIssue(issues, path, `must be < ${schema.maximum}`, { keyword: "exclusiveMaximum" });
valid = false;
}
if (typeof schema.multipleOf === "number" && schema.multipleOf > 0) {
const quotient = value / schema.multipleOf;
if (Math.abs(quotient - Math.round(quotient)) > Number.EPSILON * 10) {
pushIssue(issues, path, `must be a multiple of ${schema.multipleOf}`, { keyword: "multipleOf" });
valid = false;
}
}
return valid;
}
export function validateJsonSchemaValue(schema: unknown, value: unknown): JsonSchemaValidationResult {
const issues: JsonSchemaValidationIssue[] = [];
const success = validateSchemaNode(schema, value, [], { root: schema, seenRefs: new Set() }, issues);
return { success, issues };
}
export function isJsonSchemaValueValid(schema: unknown, value: unknown): boolean {
return validateJsonSchemaValue(schema, value).success;
}
+95 -65
View File
@@ -1,19 +1,12 @@
import { areJsonValuesEqual } from "./equality";
/**
* Hand-rolled JSON Schema meta-validator.
*
* Replaces a singleton `Ajv2020.validateSchema` call with a tiny structural
* pass that covers every JSON Schema keyword the rest of the codebase
* actually emits. The full meta-schema is not necessary because:
*
* 1. Tool schemas are authored either with Zod (validated by Zod itself) or
* TypeBox (which already constructs structurally-correct JSON Schema).
* 2. The transform pipeline in `normalize-cca.ts` / `compatibility.ts`
* mutates schemas in narrow, known ways. The meta-check just guards
* against gross structural breakage introduced by those transforms.
*
* Returns `true` if the schema is well-formed enough for downstream
* consumers, otherwise `false`. Unknown keywords are accepted (forward
* compatibility); known keywords are checked for their expected shape.
* Replaces the old AJV meta-schema check in request hot paths with a small
* structural validator for the JSON Schema subset this repo emits and forwards.
* Unknown keywords are accepted for forward compatibility; known keywords are
* checked so malformed provider payloads still fall back instead of being sent.
*/
type Json = unknown;
@@ -24,10 +17,40 @@ function isPlainObject(value: Json): value is Record<string, Json> {
const TYPE_NAMES = new Set<string>(["string", "number", "integer", "boolean", "object", "array", "null"]);
/**
* Validate a single sub-schema node. Recurses into combinators, property
* maps, and items lists. Anything unrecognized is left untouched.
*/
function isNonNegativeInteger(value: Json): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
function hasUniqueJsonValues(values: readonly unknown[]): boolean {
for (let i = 0; i < values.length; i += 1) {
for (let j = i + 1; j < values.length; j += 1) {
if (areJsonValuesEqual(values[i], values[j])) return false;
}
}
return true;
}
function checkTypeKeyword(value: Json): boolean {
if (typeof value === "string") return TYPE_NAMES.has(value);
if (!Array.isArray(value) || value.length === 0) return false;
const seen = new Set<string>();
for (const entry of value) {
if (typeof entry !== "string" || !TYPE_NAMES.has(entry) || seen.has(entry)) return false;
seen.add(entry);
}
return true;
}
function checkSchemaArray(value: Json, seen: WeakSet<object>): boolean {
return Array.isArray(value) && value.every(entry => checkNode(entry, seen));
}
function checkSchemaMap(value: Json, seen: WeakSet<object>): boolean {
if (!isPlainObject(value)) return false;
return Object.values(value).every(sub => checkNode(sub, seen));
}
/** Validate a single sub-schema node. */
function checkNode(node: Json, seen: WeakSet<object>): boolean {
// Boolean schemas (`true` / `false`) are valid JSON Schema.
if (node === true || node === false) return true;
@@ -35,56 +58,30 @@ function checkNode(node: Json, seen: WeakSet<object>): boolean {
if (seen.has(node)) return true;
seen.add(node);
// `type` must be a known type name or a non-empty array of them.
if ("type" in node) {
const t = node.type;
if (typeof t === "string") {
if (!TYPE_NAMES.has(t)) return false;
} else if (Array.isArray(t)) {
if (t.length === 0) return false;
for (const entry of t) {
if (typeof entry !== "string" || !TYPE_NAMES.has(entry)) return false;
}
} else {
return false;
}
}
if ("type" in node && !checkTypeKeyword(node.type)) return false;
// Combinators must be arrays of sub-schemas.
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
if (key in node) {
const value = node[key];
if (!Array.isArray(value)) return false;
for (const branch of value) {
if (!checkNode(branch, seen)) return false;
}
}
if (key in node && !checkSchemaArray(node[key], seen)) return false;
}
// `not` is a single sub-schema.
if ("not" in node && !checkNode(node.not, seen)) return false;
// `properties` / `patternProperties` are objects of sub-schemas.
for (const key of ["properties", "patternProperties", "$defs", "definitions"] as const) {
if (key in node) {
const map = node[key];
if (!isPlainObject(map)) return false;
for (const sub of Object.values(map)) {
if (!checkNode(sub, seen)) return false;
}
}
if (key in node && !checkSchemaMap(node[key], seen)) return false;
}
// `required` is an array of unique strings.
if ("propertyNames" in node && !checkNode(node.propertyNames, seen)) return false;
if ("contains" in node && !checkNode(node.contains, seen)) return false;
if ("required" in node) {
const value = node.required;
if (!Array.isArray(value)) return false;
const seenRequired = new Set<string>();
for (const entry of value) {
if (typeof entry !== "string") return false;
if (typeof entry !== "string" || seenRequired.has(entry)) return false;
seenRequired.add(entry);
}
}
// `items` may be a sub-schema (single) or an array of sub-schemas (tuple).
if ("items" in node) {
const items = node.items;
if (Array.isArray(items)) {
@@ -93,31 +90,64 @@ function checkNode(node: Json, seen: WeakSet<object>): boolean {
}
} else if (!checkNode(items, seen)) return false;
}
if ("prefixItems" in node && !checkSchemaArray(node.prefixItems, seen)) return false;
// `additionalProperties` may be boolean or sub-schema.
if ("additionalProperties" in node) {
const value = node.additionalProperties;
for (const key of [
"additionalProperties",
"additionalItems",
"unevaluatedProperties",
"unevaluatedItems",
] as const) {
if (!(key in node)) continue;
const value = node[key];
if (typeof value !== "boolean" && !checkNode(value, seen)) return false;
}
// `additionalItems` follows the same rule.
if ("additionalItems" in node) {
const value = node.additionalItems;
if (typeof value !== "boolean" && !checkNode(value, seen)) return false;
if ("dependentSchemas" in node && !checkSchemaMap(node.dependentSchemas, seen)) return false;
if ("dependentRequired" in node) {
const value = node.dependentRequired;
if (!isPlainObject(value)) return false;
for (const entry of Object.values(value)) {
if (!Array.isArray(entry) || !entry.every(item => typeof item === "string")) return false;
}
}
// `enum` must be a non-empty array.
if ("enum" in node) {
if (!Array.isArray(node.enum) || node.enum.length === 0) return false;
if (!Array.isArray(node.enum) || node.enum.length === 0 || !hasUniqueJsonValues(node.enum)) return false;
}
for (const key of ["minimum", "maximum", "multipleOf"] as const) {
if (key in node && typeof node[key] !== "number") return false;
}
if (node.multipleOf !== undefined && typeof node.multipleOf === "number" && node.multipleOf <= 0) return false;
for (const key of ["exclusiveMinimum", "exclusiveMaximum"] as const) {
if (key in node && typeof node[key] !== "number" && typeof node[key] !== "boolean") return false;
}
for (const key of ["minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties"] as const) {
if (key in node && !isNonNegativeInteger(node[key])) return false;
}
for (const key of ["minContains", "maxContains"] as const) {
if (key in node && !isNonNegativeInteger(node[key])) return false;
}
if ("uniqueItems" in node && typeof node.uniqueItems !== "boolean") return false;
if ("pattern" in node) {
if (typeof node.pattern !== "string") return false;
try {
new RegExp(node.pattern);
} catch {
return false;
}
}
if ("format" in node && typeof node.format !== "string") return false;
if ("nullable" in node && typeof node.nullable !== "boolean") return false;
if ("readOnly" in node && typeof node.readOnly !== "boolean") return false;
if ("writeOnly" in node && typeof node.writeOnly !== "boolean") return false;
if ("deprecated" in node && typeof node.deprecated !== "boolean") return false;
return true;
}
/**
* Validate that `schema` is structurally a valid JSON Schema (subset). Used
* in CCA-claude validation where the full AJV meta-check used to live.
*/
/** Validate that `schema` is structurally a valid JSON Schema (subset). */
export function isValidJsonSchema(schema: unknown): boolean {
try {
return checkNode(schema, new WeakSet<object>());
+84 -44
View File
@@ -1,8 +1,12 @@
import { structuredCloneJSON } from "@oh-my-pi/pi-utils";
import { type ZodType, z } from "zod/v4";
import type { ZodType } from "zod/v4";
import type { $ZodIssue as ZodIssue } from "zod/v4/core";
import type { Tool, ToolCall } from "../types";
import { fromTypeBox } from "./schema/from-typebox";
import {
isJsonSchemaValueValid,
type JsonSchemaValidationIssue,
validateJsonSchemaValue,
} from "./schema/json-schema-validator";
import { isZodSchema, zodToWireSchema } from "./schema/wire";
// ============================================================================
@@ -473,23 +477,12 @@ function setValueAtPointer(root: unknown, pointer: string, value: unknown): unkn
// ============================================================================
/**
* Resolve a JSON-Schema branch (used inside `anyOf`/`oneOf`) into a Zod
* schema we can probe for branch-membership during nullable-strip
* normalization. Cached so repeated traversals of the same schema reuse the
* compiled Zod schema.
* Test a JSON-Schema branch during nullable normalization. Kept deliberately
* small and synchronous so validation does not need to compile legacy schemas
* into another schema language.
*/
const branchZodCache = new WeakMap<object, ZodType>();
function branchAsZod(branch: unknown): ZodType | null {
if (!branch || typeof branch !== "object") return null;
let cached = branchZodCache.get(branch as object);
if (cached) return cached;
try {
cached = z.fromJSONSchema(branch as Parameters<typeof z.fromJSONSchema>[0]) as ZodType;
} catch {
return null;
}
branchZodCache.set(branch as object, cached);
return cached;
function branchMatchesSchema(branch: unknown, value: unknown): boolean {
return isJsonSchemaValueValid(branch, value);
}
function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { value: unknown; changed: boolean } {
@@ -508,8 +501,7 @@ function normalizeOptionalNullsForSchema(schema: unknown, value: unknown): { val
const normalized = normalizeOptionalNullsForSchema(branch, value);
if (!normalized.changed) continue;
const branchSchema = branchAsZod(branch);
if (branchSchema?.safeParse(normalized.value).success) {
if (branchMatchesSchema(branch, normalized.value)) {
return normalized;
}
@@ -668,6 +660,8 @@ function mapZodExpectedToJsonSchemaType(expected: unknown): string | null {
case "object":
case "null":
return expected;
case "record":
return "object";
case "int":
case "bigint":
return "integer";
@@ -754,13 +748,19 @@ function coerceArgsFromIssues(args: unknown, issues: FlatIssue[]): { value: unkn
// Public API
// ============================================================================
interface ValidationContext {
zod: ZodType;
json: Record<string, unknown>;
}
type ValidationContext =
| {
kind: "zod";
zod: ZodType;
json: Record<string, unknown>;
}
| {
kind: "json";
json: Record<string, unknown>;
};
/**
* Cache the (zod, json) pair derived from a tool's parameters schema.
* Cache the validation context derived from a tool's parameters schema.
* Keyed by the parameters object identity, which is stable across tool
* registrations.
*/
@@ -770,15 +770,61 @@ function getValidationContext(tool: Tool): ValidationContext {
let ctx = validationContextCache.get(params);
if (ctx) return ctx;
if (isZodSchema(params)) {
ctx = { zod: params, json: zodToWireSchema(params) };
ctx = { kind: "zod", zod: params, json: zodToWireSchema(params) };
} else {
const json = params as unknown as Record<string, unknown>;
ctx = { zod: fromTypeBox(json), json };
ctx = { kind: "json", json: params as unknown as Record<string, unknown> };
}
validationContextCache.set(params, ctx);
return ctx;
}
type ContextValidationResult =
| { success: true; value: unknown }
| { success: false; flatIssues: FlatIssue[]; messages: string[] };
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function preserveUnknownRootFields(input: unknown, parsed: unknown): unknown {
if (!isPlainRecord(input) || !isPlainRecord(parsed)) return parsed;
return { ...input, ...parsed };
}
function flattenJsonSchemaIssues(issues: ReadonlyArray<JsonSchemaValidationIssue>): FlatIssue[] {
return issues.map(issue => ({
keyword: issue.keyword === "type" ? "type" : "other",
instancePath: pathToPointer(issue.path),
expectedTypes: issue.expectedTypes ?? [],
}));
}
function formatIssuePath(path: ReadonlyArray<PropertyKey>): string {
return path.length === 0 ? "root" : path.map(seg => String(seg)).join("/");
}
function validateContext(ctx: ValidationContext, value: unknown): ContextValidationResult {
if (ctx.kind === "zod") {
const result = ctx.zod.safeParse(value);
if (result.success) {
return { success: true, value: preserveUnknownRootFields(value, result.data) };
}
return {
success: false,
flatIssues: flattenIssues(result.error.issues),
messages: result.error.issues.map(issue => ` - ${formatIssuePath(issue.path)}: ${issue.message}`),
};
}
const result = validateJsonSchemaValue(ctx.json, value);
if (result.success) return { success: true, value };
return {
success: false,
flatIssues: flattenJsonSchemaIssues(result.issues),
messages: result.issues.map(issue => ` - ${formatIssuePath(issue.path)}: ${issue.message}`),
};
}
const MAX_COERCION_PASSES = 5;
/**
@@ -797,15 +843,16 @@ export function validateToolCall(tools: Tool[], toolCall: ToolCall): ToolCall["a
}
/**
* Validates tool call arguments against the tool's schema (Zod, or TypeBox
* lifted into Zod). Applies LLM-quirk coercions (numeric strings, JSON-string
* Validates tool call arguments against the tool's schema (Zod or plain JSON
* Schema). Applies LLM-quirk coercions (numeric strings, JSON-string
* containers, null-for-optional, null-for-default) before declaring failure.
*
* @throws Error with a formatted message when validation cannot be reconciled.
*/
export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall["arguments"] {
const originalArgs = toolCall.arguments;
const { zod, json } = getValidationContext(tool);
const ctx = getValidationContext(tool);
const { json } = ctx;
// Always normalize first — strip null and string "null" from optional
// fields and substitute defaults. Handles LLM outputting string "null"
@@ -818,12 +865,11 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
changed = true;
}
let result = zod.safeParse(normalizedArgs);
if (result.success) return result.data as ToolCall["arguments"];
let result = validateContext(ctx, normalizedArgs);
if (result.success) return result.value as ToolCall["arguments"];
for (let pass = 0; pass < MAX_COERCION_PASSES; pass += 1) {
const flat = flattenIssues(result.error.issues);
const coercion = coerceArgsFromIssues(normalizedArgs, flat);
const coercion = coerceArgsFromIssues(normalizedArgs, result.flatIssues);
if (!coercion.changed) break;
normalizedArgs = coercion.value;
@@ -834,19 +880,13 @@ export function validateToolArguments(tool: Tool, toolCall: ToolCall): ToolCall[
normalizedArgs = nullNormalization.value;
}
result = zod.safeParse(normalizedArgs);
if (result.success) return result.data as ToolCall["arguments"];
result = validateContext(ctx, normalizedArgs);
if (result.success) return result.value as ToolCall["arguments"];
}
// Format validation errors nicely. The header phrase is asserted by
// existing tests; the detailed body is informational.
const errors =
result.error.issues
.map(issue => {
const path = issue.path.length === 0 ? "root" : issue.path.map(seg => String(seg)).join("/");
return ` - ${path}: ${issue.message}`;
})
.join("\n") || "Unknown validation error";
const errors = result.messages.join("\n") || "Unknown validation error";
const receivedArgs = changed
? {
@@ -1,6 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { type AzureOpenAIResponsesOptions, streamAzureOpenAIResponses } from "../src/providers/azure-openai-responses";
import type { Context, Model } from "../src/types";
import type { Context, Model, Tool } from "../src/types";
const originalFetch = global.fetch;
@@ -133,6 +133,44 @@ describe("azure openai responses streaming", () => {
expect(payload.cache_control).toBeUndefined();
});
it("rewrites oneOf tool schemas to anyOf for Azure Responses", async () => {
const tool: Tool = {
name: "choose",
description: "choose a branch",
parameters: {
type: "object",
properties: {
item: {
oneOf: [
{
type: "object",
properties: { kind: { const: "a" }, value: { type: "string" } },
required: ["kind", "value"],
additionalProperties: false,
},
{
type: "object",
properties: { kind: { const: "b" }, count: { type: "integer" } },
required: ["kind", "count"],
additionalProperties: false,
},
],
},
},
required: ["item"],
},
};
const payload = await captureAzurePayload({
messages: [{ role: "user", content: "Say hello", timestamp: Date.now() }],
tools: [tool],
});
const tools = payload.tools as Array<{ parameters: { properties: { item: Record<string, unknown> } } }>;
expect(tools[0].parameters.properties.item.oneOf).toBeUndefined();
expect(Array.isArray(tools[0].parameters.properties.item.anyOf)).toBe(true);
});
it("surfaces nested response.failed provider errors", async () => {
global.fetch = vi.fn(async () =>
createSseResponse([
+40 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "bun:test";
import { convertTools } from "@oh-my-pi/pi-ai/providers/google-shared";
import type { Model, TJsonSchema, Tool } from "@oh-my-pi/pi-ai/types";
import { sanitizeSchemaForCCA, sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema";
import { prepareSchemaForCCA, sanitizeSchemaForCCA, sanitizeSchemaForGoogle } from "@oh-my-pi/pi-ai/utils/schema";
function createModel(id: string): Model<"google-gemini-cli"> {
return {
@@ -47,6 +47,29 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
});
});
it("strips propertyNames before sending legacy CCA parameters", () => {
const schema = {
type: "object",
properties: {
env: {
type: "object",
propertyNames: { type: "string", pattern: "^[A-Z_]+$" },
additionalProperties: { type: "string" },
},
},
} as unknown;
expect(sanitizeSchemaForCCA(schema)).toEqual({
type: "object",
properties: {
env: {
type: "object",
properties: {},
},
},
});
});
it("uses sanitized parameters for claude models with deterministic output", () => {
const parameters = {
type: "object",
@@ -256,6 +279,22 @@ describe("Cloud Code Assist Claude tool schema conversion", () => {
properties: {},
});
});
it("falls back when CCA schema meta-validation catches malformed keywords", () => {
const parameters = {
type: "object",
properties: {
mode: { type: "string", enum: ["read", "read"] },
tags: { type: "array", items: { type: "string" }, uniqueItems: "true" },
},
required: ["mode"],
} as unknown;
expect(prepareSchemaForCCA(parameters)).toEqual({
type: "object",
properties: {},
});
});
it("keeps google sanitizer behavior for non-claude schema path", () => {
const schema = {
type: "object",
@@ -78,6 +78,76 @@ describe("Tool argument coercion", () => {
expect(result.payload).toEqual({ a: 1 });
});
it("preserves unknown root fields after Zod validation so tools can reject disabled arguments", () => {
const tool: Tool = {
name: "t4b",
description: "",
parameters: z.object({ command: z.string() }),
};
const result = validateToolArguments(tool, {
type: "toolCall",
id: "call-4b",
name: "t4b",
arguments: { command: "echo hi", async: true },
});
expect(result).toEqual({ command: "echo hi", async: true });
});
it("coerces JSON-stringified records emitted for Zod record fields", () => {
const tool: Tool = {
name: "t4c",
description: "",
parameters: z.object({ env: z.record(z.string(), z.string()) }),
};
const result = validateToolArguments(tool, {
type: "toolCall",
id: "call-4c",
name: "t4c",
arguments: { env: '{"FOO":"bar"}' },
});
expect(result).toEqual({ env: { FOO: "bar" } });
});
it("validates legacy draft-07 JSON Schema without converting it through Zod", () => {
const tool: Tool = {
name: "legacy_schema",
description: "",
parameters: {
type: "object",
properties: {
item: { $ref: "#/definitions/Item" },
name: { type: "string", nullable: true },
ids: { type: "array", items: { type: "string" }, uniqueItems: true },
},
required: ["item", "name", "ids"],
definitions: {
Item: { type: "string" },
},
},
};
const valid = validateToolArguments(tool, {
type: "toolCall",
id: "call-legacy-ok",
name: "legacy_schema",
arguments: { item: "ok", name: null, ids: ["a", "b"] },
});
expect(valid).toEqual({ item: "ok", name: null, ids: ["a", "b"] });
expect(() =>
validateToolArguments(tool, {
type: "toolCall",
id: "call-legacy-bad",
name: "legacy_schema",
arguments: { item: "ok", name: null, ids: ["a", "a"] },
}),
).toThrow("unique");
});
it("parses nested JSON arrays in string values", () => {
const tool: Tool = {
name: "t5",
+12 -4
View File
@@ -1,19 +1,27 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Changed the extension and hook runtime API by moving schema typing from direct TypeBox imports to `TSchema` from `@oh-my-pi/pi-ai`, requiring callers who use TypeScript imports of `Type` to migrate via provided injected modules
### Added
- Added `apiKey` as a supported provider override field in model config, allowing API-key-only overrides to provide fallback credentials for built-in models
- Added `supportsMultipleSystemMessages`, `allowsSyntheticReasoningContentForToolCalls`, `disableReasoningOnToolChoice`, and `levels` model-thinking compatibility fields to model configuration schemas
- Added `zod` to the Extension, Custom Tool, Hook, and Custom Command APIs as `pi.zod` so extension and plugin authors can define tool schemas with Zod without separate imports
- Added `pi.zod` as a canonical schema API for examples and extension plugins while keeping `typebox` available as legacy compatibility
- Added a `telemetry` option to `createAgentSession` for passing OpenTelemetry configuration through to the underlying Agent
### Changed
- Changed tool parameter schemas across the agent to use the shared Pi schema pipeline (`TSchema` and `fromTypeBox`) instead of direct AJV/TypeBox compilation for stricter schema validation compatibility
- Changed `pi.typebox.Type.Composite` to merge all object schemas in the provided list, enabling more than two object inputs
- Changed `pi.typebox.Type.Record` to validate record keys against the provided key schema instead of forcing string keys
- Changed `pi.typebox.Type.Array` with `uniqueItems: true` to reject duplicate items while preserving the constraint in wire schemas
- Changed `pi.typebox.Type.Object` with `additionalProperties: false` to reject unknown properties during parsing
- Changed `pi.typebox.Type.Enum` in the compatibility shim to preserve numeric TypeScript enum values
- Changed tool parameter schemas across the agent to use the shared Pi schema pipeline (`TSchema` plus Zod/JSON Schema validation) instead of direct AJV/TypeBox compilation for stricter schema validation compatibility
- Changed GitHub tool input schema shape to expose operation fields in a flat schema form without legacy `run_watch`-style nesting
- Changed Python session pooling to remove the previous 4-session retention cap and 5-minute idle-session eviction, so kernels now stay alive for a session until explicitly disposed via `disposeKernelSessionsByOwner` or `disposeAllKernelSessions`
- Changed kernel cleanup behavior to avoid automatic eviction by idle timeout and capacity pressure, so additional Python sessions are not queued behind retained-session shutdown retries
@@ -25,14 +33,14 @@
### Fixed
- Fixed `create_conventional_analysis` parsing to ignore harmless extra fields and still parse the required conventional fields
- Fixed BashTool async request validation flow so async execution remains disabled and returns the explicit `Async bash execution is disabled` error
- Fixed `task.simple` invalid `schema` and `context` argument handling to still reject unsupported fields after tool-argument validation
- Fixed subagent execution hangs by enforcing `task.maxRuntimeMs` as a wall-clock limit even when inference streaming stalls, so stuck subagents now abort and report runtime-limit exceeded
- Fixed tool schema compatibility validation by routing TypeBox schemas through shared conversion and Zod-based validation to avoid strict-schema provider mismatches
- Fixed Python execution cancellation and timeouts by escalating to kernel shutdown if `SIGINT` did not terminate a running cell within 2 seconds, preventing indefinite hangs in queued or stuck sessions
- Fixed cleanup blocking during long-running executions by forcing a kernel shutdown path when interrupt-based cancellation is ignored
- Fixed bash output emitting a spurious `[… 0 lines elided (NB) …]` marker (and reordering the artifact link before the command output) when the shell minimizer rewrote a small command's output. After `OutputSink.replace()` swapped the minimized text into the buffer, the subsequent `sink.push("[raw output: artifact://N]\n")` chunk was funneled back into the (now empty) head-retention window while the pre-replace `#totalBytes` still tracked the original raw stream — so `dump()` composed `<head=artifact-link> + <middle-elision marker against stale totals> + <tail=minimized text>` instead of `<minimized text> + <artifact link>`. `replace()` now realigns `#totalBytes`/`#totalLines`/`#sawData`/`#truncated` to the authoritative buffer and disables head retention for the lifetime of the sink, so further pushes append to the tail buffer in order. The bash executor also drops the leading `\n` on the artifact-link push when the minimized text already ends with one so the separator stays single-newline.
### Fixed
- Fixed legacy plugin extensions failing to load on Windows when they import a bare-specifier dependency from their own `node_modules` (e.g. `import YAML from "yaml"` in `supipowers`). The legacy-pi mirror resolved the dependency to its absolute path and then ran the path through `isUrlLikeSpecifier`, whose `^[A-Za-z][A-Za-z\d+.-]*:` regex matched the Windows drive letter (`C:`) and short-circuited the `pathToFileURL` conversion. The raw path was emitted into the mirrored TS source as `import x from "C:\\Users\\...\\dep\\dist\\index.js"`, where `\n`, `\U`, `\y` and other backslash sequences were eaten by the TS string-literal parser, producing nonsense package specifiers like `C:Usersjames.ompagentextensionssupipowers\node_modulesyamldistindex.js` that Bun's resolver rejected with `Cannot find package …`. `isUrlLikeSpecifier` now rejects `^[A-Za-z]:[\\/]` first, so Windows absolute paths flow through `pathToFileURL` like every other absolute path and reach the mirror as proper `file:///C:/...` URLs.
## [15.0.2] - 2026-05-15
+12 -14
View File
@@ -20,20 +20,18 @@ const changelogCategoryLiteral = z.enum([
* are identical across phases — only the surrounding tool `description`
* differs to reflect the input the phase is summarizing.
*/
export const conventionalAnalysisParameters = z
.object({
type: z.enum(["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert"]),
scope: z.union([z.string(), z.null()]),
details: z.array(
z.object({
text: z.string(),
changelog_category: changelogCategoryLiteral.optional(),
user_visible: z.boolean().optional(),
}),
),
issue_refs: z.array(z.string()),
})
.strict();
export const conventionalAnalysisParameters = z.object({
type: z.enum(["feat", "fix", "refactor", "docs", "test", "chore", "style", "perf", "build", "ci", "revert"]),
scope: z.union([z.string(), z.null()]),
details: z.array(
z.object({
text: z.string(),
changelog_category: changelogCategoryLiteral.optional(),
user_visible: z.boolean().optional(),
}),
),
issue_refs: z.array(z.string()),
});
export interface ConventionalAnalysisTool {
name: "create_conventional_analysis";
@@ -165,12 +165,13 @@ function validateProviderConfiguration(
!config.baseUrl &&
!config.headers &&
!config.compat &&
!config.apiKey &&
!config.disableStrictTools &&
!hasModelOverrides &&
!config.discovery
) {
throw new Error(
`Provider ${providerName}: must specify "baseUrl", "headers", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`,
`Provider ${providerName}: must specify "baseUrl", "headers", "apiKey", "compat", "disableStrictTools", "modelOverrides", "discovery", or "models"`,
);
}
}
@@ -21,6 +21,7 @@ const ReasoningEffortMapSchema = z.object({
export const OpenAICompatSchema = z.object({
supportsStore: z.boolean().optional(),
supportsDeveloperRole: z.boolean().optional(),
supportsMultipleSystemMessages: z.boolean().optional(),
supportsReasoningEffort: z.boolean().optional(),
reasoningEffortMap: ReasoningEffortMapSchema.optional(),
maxTokensField: z.enum(["max_completion_tokens", "max_tokens"]).optional(),
@@ -31,9 +32,11 @@ export const OpenAICompatSchema = z.object({
requiresThinkingAsText: z.boolean().optional(),
reasoningContentField: z.enum(["reasoning_content", "reasoning", "reasoning_text"]).optional(),
requiresReasoningContentForToolCalls: z.boolean().optional(),
allowsSyntheticReasoningContentForToolCalls: z.boolean().optional(),
requiresAssistantContentForToolCalls: z.boolean().optional(),
supportsToolChoice: z.boolean().optional(),
disableReasoningOnForcedToolChoice: z.boolean().optional(),
disableReasoningOnToolChoice: z.boolean().optional(),
thinkingFormat: z.enum(["openai", "openrouter", "zai", "qwen", "qwen-chat-template"]).optional(),
openRouterRouting: OpenRouterRoutingSchema.optional(),
vercelGatewayRouting: VercelGatewayRoutingSchema.optional(),
@@ -57,6 +60,7 @@ const ModelThinkingSchema = z.object({
maxLevel: EffortSchema,
mode: ThinkingControlModeSchema,
defaultLevel: EffortSchema.optional(),
levels: z.array(EffortSchema).optional(),
});
const ModelDefinitionSchema = z.object({
@@ -21,6 +21,7 @@
* `@sinclair/typebox` directly in their own package.
*/
import { areJsonValuesEqual } from "@oh-my-pi/pi-ai/utils/schema";
import {
type ZodArray,
type ZodEnum,
@@ -105,6 +106,13 @@ function withMeta<T extends ZodType>(schema: T, opts: Meta | undefined): T {
let out: ZodType = schema;
if (typeof opts.description === "string") out = out.describe(opts.description);
if ("default" in opts) out = out.default(opts.default as never) as unknown as ZodType;
const metadata: Record<string, unknown> = {};
for (const [key, value] of Object.entries(opts)) {
if (key === "description" || key === "default" || key === "additionalProperties") continue;
metadata[key] = value;
}
if (Object.keys(metadata).length > 0) out = out.meta(metadata);
return out as T;
}
@@ -213,22 +221,59 @@ function tIntersect(schemas: readonly ZodType[], opts?: Meta): ZodType {
return withMeta(out, opts);
}
function tEnum<T extends Record<string, string | number>>(values: T, opts?: Meta): ZodType {
// Accepts either a plain object (TS enum / record of name→value) or a
// pre-built array; both are tolerated by `z.enum`. We collapse to values
// because TypeBox's `Type.Enum` discards the keys for the JSON Schema.
const list = Array.isArray(values) ? (values as unknown as (string | number)[]) : Object.values(values);
return withMeta(z.enum(list as [string, ...string[]]), opts);
function isArrayIndexKey(key: string): boolean {
if (!/^(?:0|[1-9]\\d*)$/.test(key)) return false;
const index = Number(key);
return Number.isSafeInteger(index) && index >= 0;
}
function uniqueLiteralValues(values: readonly (string | number | boolean)[]): Array<string | number | boolean> {
const unique: Array<string | number | boolean> = [];
for (const value of values) {
if (!unique.some(existing => existing === value)) unique.push(value);
}
return unique;
}
function literalUnion(values: readonly (string | number | boolean)[], opts?: Meta): ZodType {
const unique = uniqueLiteralValues(values);
if (unique.length === 0) return withMeta(z.never(), opts);
if (unique.length === 1) return withMeta(z.literal(unique[0] as string | number | boolean), opts);
const schemas = unique.map(value => z.literal(value as string | number | boolean)) as unknown as [
ZodType,
ZodType,
...ZodType[],
];
return withMeta(z.union(schemas), opts);
}
function tEnum<T extends Record<string, string | number> | readonly (string | number)[]>(
values: T,
opts?: Meta,
): ZodType {
const list = Array.isArray(values)
? values
: Object.entries(values)
.filter(([key, value]) => !(isArrayIndexKey(key) && typeof value === "string"))
.map(([, value]) => value);
return literalUnion(list, opts);
}
function tArray<E extends ZodType>(item: E, opts?: ArrayOpts): ZodType {
let arr = z.array(item);
let arr: ZodType = z.array(item);
if (opts) {
if (typeof opts.minItems === "number") arr = arr.min(opts.minItems);
if (typeof opts.maxItems === "number") arr = arr.max(opts.maxItems);
// `uniqueItems` is observably useful only at JSON Schema emit time —
// providers either honor it or ignore it. Zod has no native equivalent,
// so we encode it as schema metadata for the wire output to surface.
if (typeof opts.minItems === "number") arr = (arr as ZodArray<E>).min(opts.minItems);
if (typeof opts.maxItems === "number") arr = (arr as ZodArray<E>).max(opts.maxItems);
if (opts.uniqueItems === true) {
arr = arr.refine(items => {
if (!Array.isArray(items)) return true;
for (let i = 0; i < items.length; i += 1) {
for (let j = i + 1; j < items.length; j += 1) {
if (areJsonValuesEqual(items[i], items[j])) return false;
}
}
return true;
}, "Expected array items to be unique");
}
}
return withMeta(arr, opts);
}
@@ -249,9 +294,7 @@ function tObject<P extends ZodRawShape>(properties: P, opts?: ObjectOpts): ZodOb
let obj = z.object(properties);
if (opts && opts.additionalProperties !== undefined) {
if (opts.additionalProperties === false) {
// `.strict()` would *reject* extra keys; for parity with the looser
// real-TypeBox behavior we keep the default (strip-on-parse) which
// still serializes to `additionalProperties: false`.
obj = obj.strict() as unknown as ZodObject<P>;
} else if (opts.additionalProperties === true) {
obj = obj.catchall(z.any()) as unknown as ZodObject<P>;
} else {
@@ -261,11 +304,8 @@ function tObject<P extends ZodRawShape>(properties: P, opts?: ObjectOpts): ZodOb
return withMeta(obj, opts);
}
function tRecord<V extends ZodType>(_key: ZodType, value: V, opts?: Meta): ZodType {
// JSON Schema `Type.Record(K, V)` is always keyed by strings on the wire
// (no provider honors numeric keys), so we ignore the key schema beyond
// the implicit string constraint.
return withMeta(z.record(z.string(), value) as unknown as ZodType, opts);
function tRecord<V extends ZodType>(key: ZodType, value: V, opts?: Meta): ZodType {
return withMeta(z.record(key as never, value as never) as unknown as ZodType, opts);
}
function tOptional<E extends ZodType>(schema: E, _opts?: Meta): ZodOptional<E> {
@@ -299,13 +339,15 @@ function tOmit<P extends ZodRawShape, K extends keyof P>(obj: ZodObject<P>, keys
return obj.omit(mask as never) as unknown as ZodObject<Omit<P, K>>;
}
function tComposite<A extends ZodRawShape, B extends ZodRawShape>(
objects: readonly [ZodObject<A>, ZodObject<B>],
): ZodObject<A & B> {
// `Type.Composite([A, B])` flattens objects into a single object schema
// rather than producing an intersection. Mirror that via Zod's extend.
const [a, b] = objects;
return a.extend(b.shape) as unknown as ZodObject<A & B>;
function tComposite(objects: readonly ZodObject<ZodRawShape>[], opts?: Meta): ZodObject<ZodRawShape> {
// `Type.Composite([...])` flattens every object schema into one object schema
// rather than producing an intersection. Mirror that via repeated `extend`.
if (objects.length === 0) return withMeta(z.object({}), opts) as ZodObject<ZodRawShape>;
let out = objects[0] as ZodObject<ZodRawShape>;
for (let i = 1; i < objects.length; i += 1) {
out = out.extend(objects[i].shape) as ZodObject<ZodRawShape>;
}
return withMeta(out, opts) as ZodObject<ZodRawShape>;
}
// ---------------------------------------------------------------------------
+2 -7
View File
@@ -7,7 +7,7 @@
import path from "node:path";
import type { AgentEvent, AgentIdentity, AgentTelemetryConfig, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { recordHandoff, resolveTelemetry } from "@oh-my-pi/pi-agent-core";
import { fromTypeBox } from "@oh-my-pi/pi-ai/utils/schema";
import { isJsonSchemaValueValid } from "@oh-my-pi/pi-ai/utils/schema";
import { logger, prompt, untilAborted } from "@oh-my-pi/pi-utils";
import { ModelRegistry } from "../config/model-registry";
import { resolveModelOverrideWithAuthFallback } from "../config/model-resolver";
@@ -209,12 +209,7 @@ function buildOutputValidator(schema: unknown): { validate?: (value: unknown) =>
if (error) return { error };
if (normalized === undefined) return {};
const jsonSchema = jtdToJsonSchema(normalized);
try {
const zod = fromTypeBox(jsonSchema);
return { validate: value => zod.safeParse(value).success };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
return { validate: value => isJsonSchemaValueValid(jsonSchema, value) };
}
function tryParseJsonOutput(text: string): unknown | undefined {
+19 -14
View File
@@ -5,9 +5,14 @@
*/
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { TSchema } from "@oh-my-pi/pi-ai/types";
import { dereferenceJsonSchema, fromTypeBox, sanitizeSchemaForStrictMode } from "@oh-my-pi/pi-ai/utils/schema";
import type { ZodType } from "zod/v4";
import type { $ZodIssue as ZodIssue } from "zod/v4/core";
import {
dereferenceJsonSchema,
isValidJsonSchema,
type JsonSchemaValidationIssue,
type JsonSchemaValidationResult,
sanitizeSchemaForStrictMode,
validateJsonSchemaValue,
} from "@oh-my-pi/pi-ai/utils/schema";
import { subprocessToolRegistry } from "../task/subprocess-tool-registry";
import type { ToolSession } from ".";
import { jtdToJsonSchema, normalizeSchema } from "./jtd-to-json-schema";
@@ -28,12 +33,12 @@ function formatSchema(schema: unknown): string {
}
}
function formatZodIssues(issues: ReadonlyArray<ZodIssue> | undefined): string {
function formatJsonSchemaIssues(issues: ReadonlyArray<JsonSchemaValidationIssue> | undefined): string {
if (!issues || issues.length === 0) return "Unknown schema validation error.";
return issues
.map(issue => {
const path = issue.path.length === 0 ? "" : `${issue.path.map(seg => String(seg)).join("/")}: `;
return `${path}${issue.message ?? "invalid"}`;
return `${path}${issue.message}`;
})
.join("; ");
}
@@ -88,11 +93,11 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
readonly intent = "omit" as const;
lenientArgValidation = true;
readonly #validate?: ZodType;
readonly #validate?: (value: unknown) => JsonSchemaValidationResult;
#schemaValidationFailures = 0;
constructor(session: ToolSession) {
let validate: ZodType | undefined;
let validate: ((value: unknown) => JsonSchemaValidationResult) | undefined;
let parameters: TSchema;
try {
@@ -106,10 +111,10 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
}
if (normalizedSchema !== undefined && normalizedSchema !== false && !schemaError) {
try {
validate = fromTypeBox(normalizedSchema as Record<string, unknown> | boolean);
} catch (err) {
schemaError = err instanceof Error ? err.message : String(err);
if (!isValidJsonSchema(normalizedSchema)) {
schemaError = "invalid JSON schema";
} else {
validate = value => validateJsonSchemaValue(normalizedSchema, value);
}
}
@@ -141,7 +146,7 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
}
parameters = wrapYieldParameters(dataSchema);
JSON.stringify(parameters);
fromTypeBox(parameters as Record<string, unknown>);
if (!isValidJsonSchema(parameters)) throw new Error("yield parameters schema is invalid");
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
parameters = wrapYieldParameters(
@@ -188,11 +193,11 @@ export class YieldTool implements AgentTool<TSchema, YieldDetails> {
throw new Error("data is required when yield indicates success");
}
if (this.#validate) {
const parsed = this.#validate.safeParse(data);
const parsed = this.#validate(data);
if (!parsed.success) {
this.#schemaValidationFailures++;
if (this.#schemaValidationFailures <= 1) {
throw new Error(`Output does not match schema: ${formatZodIssues(parsed.error.issues)}`);
throw new Error(`Output does not match schema: ${formatJsonSchemaIssues(parsed.issues)}`);
}
schemaValidationOverridden = true;
}
@@ -0,0 +1,33 @@
import { describe, expect, it } from "bun:test";
import type { AssistantMessage } from "@oh-my-pi/pi-ai";
import { createConventionalAnalysisTool, parseConventionalAnalysisResponse } from "../src/commit/shared-llm";
describe("commit shared LLM parsing", () => {
it("ignores harmless extra fields in conventional analysis tool output", () => {
const tool = createConventionalAnalysisTool("Analyze a diff.");
const message = {
role: "assistant",
content: [
{
type: "toolCall",
id: "call-analysis",
name: tool.name,
arguments: {
type: "fix",
scope: null,
details: [],
issue_refs: [],
summary: "fix: handle parser edge case",
},
},
],
} as unknown as AssistantMessage;
expect(parseConventionalAnalysisResponse(message, tool)).toEqual({
type: "fix",
scope: null,
details: [],
issueRefs: [],
});
});
});
@@ -0,0 +1,47 @@
import { describe, expect, it } from "bun:test";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { Type } from "../../src/extensibility/typebox";
describe("pi.typebox compatibility shim", () => {
it("rejects extra properties when additionalProperties is false", () => {
const schema = Type.Object({ path: Type.String() }, { additionalProperties: false });
expect(schema.safeParse({ path: "README.md" }).success).toBe(true);
expect(schema.safeParse({ path: "README.md", mode: "delete" }).success).toBe(false);
});
it("preserves numeric enum values from TypeScript enum objects", () => {
const schema = Type.Enum({ 0: "Fast", 1: "Slow", Fast: 0, Slow: 1 });
expect(schema.safeParse(0).success).toBe(true);
expect(schema.safeParse(1).success).toBe(true);
expect(schema.safeParse("Fast").success).toBe(false);
});
it("enforces and emits uniqueItems for arrays", () => {
const schema = Type.Array(Type.String(), { uniqueItems: true });
const wire = toolWireSchema({ name: "files", description: "", parameters: schema });
expect(schema.safeParse(["a.ts", "b.ts"]).success).toBe(true);
expect(schema.safeParse(["a.ts", "a.ts"]).success).toBe(false);
expect(wire.uniqueItems).toBe(true);
});
it("respects record key schemas", () => {
const schema = Type.Record(Type.Literal("target"), Type.String());
expect(schema.safeParse({ target: "ok" }).success).toBe(true);
expect(schema.safeParse({ other: "bad" }).success).toBe(false);
});
it("merges every object passed to Composite", () => {
const schema = Type.Composite([
Type.Object({ a: Type.String() }),
Type.Object({ b: Type.String() }),
Type.Object({ c: Type.String() }),
]);
expect(schema.safeParse({ a: "a", b: "b", c: "c" }).success).toBe(true);
expect(schema.safeParse({ a: "a", b: "b" }).success).toBe(false);
});
});
@@ -488,6 +488,26 @@ describe("ModelRegistry", () => {
}
});
test("apiKey-only override supplies fallback auth for built-in models", async () => {
const originalOpenAiKey = Bun.env.OPENAI_API_KEY;
delete Bun.env.OPENAI_API_KEY;
try {
writeRawModelsJson({
openai: {
apiKey: "issue-typed-key",
},
});
const registry = new ModelRegistry(authStorage, modelsJsonPath);
const openaiModels = getModelsForProvider(registry, "openai");
expect(openaiModels.length).toBeGreaterThan(0);
await expect(registry.getApiKey(openaiModels[0])).resolves.toBe("issue-typed-key");
} finally {
if (originalOpenAiKey === undefined) delete Bun.env.OPENAI_API_KEY;
else Bun.env.OPENAI_API_KEY = originalOpenAiKey;
}
});
test("baseUrl-only override does not affect other providers", () => {
writeRawModelsJson({
anthropic: overrideConfig("https://my-proxy.example.com/v1"),
@@ -551,6 +571,9 @@ describe("ModelRegistry", () => {
compat: {
supportsUsageInStreaming: false,
supportsStrictMode: false,
supportsMultipleSystemMessages: false,
disableReasoningOnToolChoice: true,
allowsSyntheticReasoningContentForToolCalls: false,
},
},
});
@@ -561,6 +584,9 @@ describe("ModelRegistry", () => {
for (const model of models) {
expect(getOpenAICompat(model)?.supportsUsageInStreaming).toBe(false);
expect(getOpenAICompat(model)?.supportsStrictMode).toBe(false);
expect(getOpenAICompat(model)?.supportsMultipleSystemMessages).toBe(false);
expect(getOpenAICompat(model)?.disableReasoningOnToolChoice).toBe(true);
expect(getOpenAICompat(model)?.allowsSyntheticReasoningContentForToolCalls).toBe(false);
}
});
@@ -1043,6 +1069,7 @@ describe("ModelRegistry", () => {
mode: "anthropic-adaptive",
minLevel: Effort.Minimal,
maxLevel: Effort.High,
levels: [Effort.Minimal, Effort.High],
};
writeModelsJson({
@@ -1,5 +1,6 @@
import { describe, expect, it } from "bun:test";
import type { AgentToolContext } from "@oh-my-pi/pi-agent-core";
import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation";
import type { BashInterceptorRule } from "../../src/config/settings-schema";
import type { ToolSession } from "../../src/tools";
import { BashTool } from "../../src/tools/bash";
@@ -57,6 +58,20 @@ describe("BashTool interception", () => {
});
});
describe("BashTool argument validation", () => {
it("preserves async requests so disabled async mode returns the explicit error", async () => {
const tool = createBashTool([]);
const args = validateToolArguments(tool, {
type: "toolCall",
id: "tool-call",
name: tool.name,
arguments: { command: "echo should-not-run", async: true },
});
await expect(tool.execute("tool-call", args)).rejects.toThrow("Async bash execution is disabled");
});
});
describe("BashTool head/tail stripping", () => {
function createBashToolWithStrip(stripEnabled: boolean): BashTool {
const session = {
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it, vi } from "bun:test";
import { toolWireSchema } from "@oh-my-pi/pi-ai/utils/schema";
import { validateToolArguments } from "@oh-my-pi/pi-ai/utils/validation";
import { Settings } from "../../src/config/settings";
import { TaskTool } from "../../src/task";
import * as discoveryModule from "../../src/task/discovery";
@@ -93,6 +94,18 @@ describe("task.simple", () => {
tasks: [{ id: "One", description: "label", assignment: "Do the thing." }],
} as TaskParams);
expect(getFirstText(schemaFreeResult)).toContain("does not accept `schema`");
const validatedSchemaFreeParams = validateToolArguments(schemaFreeTool, {
type: "toolCall",
id: "tool-1-validated",
name: schemaFreeTool.name,
arguments: {
agent: "task",
schema: '{"properties":{"ok":{"type":"boolean"}}}',
tasks: [{ id: "One", description: "label", assignment: "Do the thing." }],
},
});
const validatedSchemaFreeResult = await schemaFreeTool.execute("tool-1-validated", validatedSchemaFreeParams);
expect(getFirstText(validatedSchemaFreeResult)).toContain("does not accept `schema`");
const independentTool = await TaskTool.create(createSession({ "task.simple": "independent" }));
const independentResult = await independentTool.execute("tool-2", {
@@ -101,5 +114,17 @@ describe("task.simple", () => {
tasks: [{ id: "Two", description: "label", assignment: "Do the independent thing." }],
} as TaskParams);
expect(getFirstText(independentResult)).toContain("does not accept `context`");
const validatedIndependentParams = validateToolArguments(independentTool, {
type: "toolCall",
id: "tool-2-validated",
name: independentTool.name,
arguments: {
agent: "task",
context: "Shared background",
tasks: [{ id: "Two", description: "label", assignment: "Do the independent thing." }],
},
});
const validatedIndependentResult = await independentTool.execute("tool-2-validated", validatedIndependentParams);
expect(getFirstText(validatedIndependentResult)).toContain("does not accept `context`");
});
});