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:
@@ -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,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>());
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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`");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user