perf(test): slim OpenAI and Pi runner imports

This commit is contained in:
Peter Steinberger
2026-04-20 12:26:30 +01:00
parent c700bfc35d
commit b4f12bb4c3
11 changed files with 446 additions and 335 deletions

View File

@@ -0,0 +1,54 @@
import { readStringValue } from "../shared/string-coerce.js";
import { resolveProviderRequestCapabilities } from "./provider-attribution.js";
type OpenAITransportKind = "stream" | "websocket";
type OpenAIStrictToolModel = {
provider?: unknown;
api?: unknown;
baseUrl?: unknown;
id?: unknown;
compat?: { supportsStore?: boolean };
};
const optionalString = readStringValue;
export function resolvesToNativeOpenAIStrictTools(
model: OpenAIStrictToolModel,
transport: OpenAITransportKind,
): boolean {
const capabilities = resolveProviderRequestCapabilities({
provider: optionalString(model.provider),
api: optionalString(model.api),
baseUrl: optionalString(model.baseUrl),
capability: "llm",
transport,
modelId: optionalString(model.id),
compat:
model.compat && typeof model.compat === "object"
? (model.compat as { supportsStore?: boolean })
: undefined,
});
if (!capabilities.usesKnownNativeOpenAIRoute) {
return false;
}
return (
capabilities.provider === "openai" ||
capabilities.provider === "openai-codex" ||
capabilities.provider === "azure-openai" ||
capabilities.provider === "azure-openai-responses"
);
}
export function resolveOpenAIStrictToolSetting(
model: OpenAIStrictToolModel,
options?: { transport?: OpenAITransportKind; supportsStrictMode?: boolean },
): boolean | undefined {
if (resolvesToNativeOpenAIStrictTools(model, options?.transport ?? "stream")) {
return true;
}
if (options?.supportsStrictMode) {
return false;
}
return undefined;
}

View File

@@ -0,0 +1,27 @@
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { log } from "./pi-embedded-runner/logger.js";
export type OpenAITextVerbosity = "low" | "medium" | "high";
function normalizeOpenAITextVerbosity(value: unknown): OpenAITextVerbosity | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "low" || normalized === "medium" || normalized === "high") {
return normalized;
}
return undefined;
}
export function resolveOpenAITextVerbosity(
extraParams: Record<string, unknown> | undefined,
): OpenAITextVerbosity | undefined {
const raw = extraParams?.textVerbosity ?? extraParams?.text_verbosity;
const normalized = normalizeOpenAITextVerbosity(raw);
if (raw !== undefined && normalized === undefined) {
const rawSummary = typeof raw === "string" ? raw : typeof raw;
log.warn(`ignoring invalid OpenAI text verbosity param: ${rawSummary}`);
}
return normalized;
}

View File

@@ -1,23 +1,13 @@
import { readStringValue } from "../shared/string-coerce.js";
import { normalizeToolParameterSchema } from "./pi-tools.schema.js";
import { resolveProviderRequestCapabilities } from "./provider-attribution.js";
type OpenAITransportKind = "stream" | "websocket";
type OpenAIStrictToolModel = {
provider?: unknown;
api?: unknown;
baseUrl?: unknown;
id?: unknown;
compat?: { supportsStore?: boolean };
};
import { normalizeToolParameterSchema } from "./pi-tools-parameter-schema.js";
export {
resolveOpenAIStrictToolSetting,
resolvesToNativeOpenAIStrictTools,
} from "./openai-strict-tool-setting.js";
type ToolWithParameters = {
parameters: unknown;
};
const optionalString = readStringValue;
export function normalizeStrictOpenAIJsonSchema(schema: unknown): unknown {
return normalizeStrictOpenAIJsonSchemaRecursive(normalizeToolParameterSchema(schema ?? {}));
}
@@ -128,43 +118,3 @@ export function resolveOpenAIStrictToolFlagForInventory(
}
return tools.every((tool) => isStrictOpenAIJsonSchemaCompatible(tool.parameters));
}
export function resolvesToNativeOpenAIStrictTools(
model: OpenAIStrictToolModel,
transport: OpenAITransportKind,
): boolean {
const capabilities = resolveProviderRequestCapabilities({
provider: optionalString(model.provider),
api: optionalString(model.api),
baseUrl: optionalString(model.baseUrl),
capability: "llm",
transport,
modelId: optionalString(model.id),
compat:
model.compat && typeof model.compat === "object"
? (model.compat as { supportsStore?: boolean })
: undefined,
});
if (!capabilities.usesKnownNativeOpenAIRoute) {
return false;
}
return (
capabilities.provider === "openai" ||
capabilities.provider === "openai-codex" ||
capabilities.provider === "azure-openai" ||
capabilities.provider === "azure-openai-responses"
);
}
export function resolveOpenAIStrictToolSetting(
model: OpenAIStrictToolModel,
options?: { transport?: OpenAITransportKind; supportsStrictMode?: boolean },
): boolean | undefined {
if (resolvesToNativeOpenAIStrictTools(model, options?.transport ?? "stream")) {
return true;
}
if (options?.supportsStrictMode) {
return false;
}
return undefined;
}

View File

@@ -2,13 +2,13 @@ import type { StreamFn } from "@mariozechner/pi-agent-core";
import { readStringValue } from "../shared/string-coerce.js";
import { mapOpenAIReasoningEffortForModel } from "./openai-reasoning-compat.js";
import { normalizeOpenAIReasoningEffort } from "./openai-reasoning-effort.js";
import { resolveOpenAITextVerbosity } from "./openai-text-verbosity.js";
import type {
FunctionToolDefinition,
InputItem,
ResponseCreateEvent,
WarmUpEvent,
} from "./openai-ws-types.js";
import { resolveOpenAITextVerbosity } from "./pi-embedded-runner/openai-stream-wrappers.js";
import { resolveProviderRequestPolicyConfig } from "./provider-request-config.js";
import { stripSystemPromptCacheBoundary } from "./system-prompt-cache-boundary.js";

View File

@@ -213,6 +213,11 @@ const { MockManager } = vi.hoisted(() => {
return { MockManager: TrackedMockManager };
});
vi.mock("../plugins/provider-runtime.js", () => ({
resolveProviderTransportTurnStateWithPlugin: () => undefined,
resolveProviderWebSocketSessionPolicyWithPlugin: () => undefined,
}));
// Track if streamSimple (HTTP fallback) was called
const streamSimpleCalls: Array<{ model: unknown; context: unknown; options?: unknown }> = [];
const mockStreamSimple = vi.fn((model: unknown, context: unknown, options?: unknown) => {
@@ -2526,6 +2531,7 @@ describe("createOpenAIWebSocketStreamFn", () => {
it("keeps websocket degraded for the session until the cool-down expires", async () => {
openAIWsStreamTesting.setWsDegradeCooldownMsForTest(50);
MockManager.globalConnectShouldFail = true;
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_000);
try {
const sessionId = "sess-degraded-cooldown";
@@ -2560,7 +2566,7 @@ describe("createOpenAIWebSocketStreamFn", () => {
expect(MockManager.instances).toHaveLength(2);
expect(cooledManager.connectCallCount).toBe(0);
await new Promise((resolve) => setTimeout(resolve, 60));
nowSpy.mockReturnValue(1_060);
const thirdStream = streamFn(
modelStub as Parameters<typeof streamFn>[0],
@@ -2579,6 +2585,7 @@ describe("createOpenAIWebSocketStreamFn", () => {
});
await new Promise((resolve) => setImmediate(resolve));
} finally {
nowSpy.mockRestore();
MockManager.globalConnectShouldFail = false;
openAIWsStreamTesting.setWsDegradeCooldownMsForTest();
releaseWsSession("sess-degraded-cooldown");

View File

@@ -41,7 +41,7 @@ import {
normalizeAssistantPhase,
} from "../shared/chat-message-content.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { resolveOpenAIStrictToolSetting } from "./openai-tool-schema.js";
import { resolveOpenAIStrictToolSetting } from "./openai-strict-tool-setting.js";
import {
getOpenAIWebSocketErrorDetails,
OpenAIWebSocketManager,

View File

@@ -3,6 +3,89 @@ import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { __testing as extraParamsTesting } from "./pi-embedded-runner/extra-params.js";
vi.mock("../plugins/provider-hook-runtime.js", () => ({
__testing: {
buildHookProviderCacheKey: () => "test-provider-hook-cache-key",
},
prepareProviderExtraParams: () => undefined,
resetProviderRuntimeHookCacheForTest: () => {},
wrapProviderStreamFn: (params: { context: { streamFn?: StreamFn } }) => params.context.streamFn,
}));
vi.mock("./codex-native-web-search.js", () => ({
patchCodexNativeWebSearchPayload: (params: {
payload: unknown;
config?: {
tools?: {
web?: {
search?: {
openaiCodex?: {
mode?: string;
allowedDomains?: string[];
};
};
};
};
};
}) => {
if (!params.payload || typeof params.payload !== "object") {
return { status: "payload_not_object" };
}
const payload = params.payload as { tools?: Array<Record<string, unknown>> };
if (payload.tools?.some((tool) => tool.type === "web_search")) {
return { status: "native_tool_already_present" };
}
const nativeConfig = params.config?.tools?.web?.search?.openaiCodex;
payload.tools = [
...(payload.tools ?? []),
{
type: "web_search",
external_web_access: nativeConfig?.mode === "live",
...(nativeConfig?.allowedDomains
? { filters: { allowed_domains: nativeConfig.allowedDomains } }
: {}),
},
];
return { status: "injected" };
},
resolveCodexNativeSearchActivation: (params: {
config?: {
auth?: { profiles?: Record<string, { provider?: string }> };
tools?: {
web?: {
search?: {
enabled?: boolean;
openaiCodex?: { enabled?: boolean; mode?: string };
};
};
};
};
modelProvider?: string;
modelApi?: string;
}) => {
const search = params.config?.tools?.web?.search;
const codex = search?.openaiCodex;
const nativeEligible =
params.modelProvider === "openai-codex" || params.modelApi === "openai-codex-responses";
const hasRequiredAuth =
params.modelProvider !== "openai-codex" ||
Object.values(params.config?.auth?.profiles ?? {}).some(
(profile) => profile.provider === "openai-codex",
);
const active =
search?.enabled !== false && codex?.enabled === true && nativeEligible && hasRequiredAuth;
return {
globalWebSearchEnabled: search?.enabled !== false,
codexNativeEnabled: codex?.enabled === true,
codexMode: codex?.mode === "live" ? "live" : "cached",
nativeEligible,
hasRequiredAuth,
state: active ? "native_active" : "managed_only",
...(active ? {} : { inactiveReason: "test_inactive" }),
};
},
}));
const ANTHROPIC_DEFAULT_BETAS = [
"fine-grained-tool-streaming-2025-05-14",
"interleaved-thinking-2025-05-14",

View File

@@ -12,12 +12,13 @@ import {
applyOpenAIResponsesPayloadPolicy,
resolveOpenAIResponsesPayloadPolicy,
} from "../openai-responses-payload-policy.js";
import { resolveOpenAITextVerbosity, type OpenAITextVerbosity } from "../openai-text-verbosity.js";
import { resolveProviderRequestPolicyConfig } from "../provider-request-config.js";
import { log } from "./logger.js";
import { streamWithPayloadPatch } from "./stream-payload-utils.js";
type OpenAIServiceTier = "auto" | "default" | "flex" | "priority";
type OpenAITextVerbosity = "low" | "medium" | "high";
export { resolveOpenAITextVerbosity };
function resolveOpenAIRequestCapabilities(model: {
api?: unknown;
@@ -106,29 +107,6 @@ export function resolveOpenAIServiceTier(
return normalized;
}
function normalizeOpenAITextVerbosity(value: unknown): OpenAITextVerbosity | undefined {
if (typeof value !== "string") {
return undefined;
}
const normalized = normalizeOptionalLowercaseString(value);
if (normalized === "low" || normalized === "medium" || normalized === "high") {
return normalized;
}
return undefined;
}
export function resolveOpenAITextVerbosity(
extraParams: Record<string, unknown> | undefined,
): OpenAITextVerbosity | undefined {
const raw = extraParams?.textVerbosity ?? extraParams?.text_verbosity;
const normalized = normalizeOpenAITextVerbosity(raw);
if (raw !== undefined && normalized === undefined) {
const rawSummary = typeof raw === "string" ? raw : typeof raw;
log.warn(`ignoring invalid OpenAI text verbosity param: ${rawSummary}`);
}
return normalized;
}
function normalizeOpenAIFastMode(value: unknown): boolean | undefined {
if (typeof value === "boolean") {
return value;

View File

@@ -0,0 +1,257 @@
import type { ModelCompatConfig } from "../config/types.models.js";
import { stripUnsupportedSchemaKeywords } from "../plugin-sdk/provider-tools.js";
import { resolveUnsupportedToolSchemaKeywords } from "../plugins/provider-model-compat.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
export type ToolParameterSchemaOptions = {
modelProvider?: string;
modelId?: string;
modelCompat?: ModelCompatConfig;
};
function extractEnumValues(schema: unknown): unknown[] | undefined {
if (!schema || typeof schema !== "object") {
return undefined;
}
const record = schema as Record<string, unknown>;
if (Array.isArray(record.enum)) {
return record.enum;
}
if ("const" in record) {
return [record.const];
}
const variants = Array.isArray(record.anyOf)
? record.anyOf
: Array.isArray(record.oneOf)
? record.oneOf
: null;
if (variants) {
const values = variants.flatMap((variant) => {
const extracted = extractEnumValues(variant);
return extracted ?? [];
});
return values.length > 0 ? values : undefined;
}
return undefined;
}
function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
if (!existing) {
return incoming;
}
if (!incoming) {
return existing;
}
const existingEnum = extractEnumValues(existing);
const incomingEnum = extractEnumValues(incoming);
if (existingEnum || incomingEnum) {
const values = Array.from(new Set([...(existingEnum ?? []), ...(incomingEnum ?? [])]));
const merged: Record<string, unknown> = {};
for (const source of [existing, incoming]) {
if (!source || typeof source !== "object") {
continue;
}
const record = source as Record<string, unknown>;
for (const key of ["title", "description", "default"]) {
if (!(key in merged) && key in record) {
merged[key] = record[key];
}
}
}
const types = new Set(values.map((value) => typeof value));
if (types.size === 1) {
merged.type = Array.from(types)[0];
}
merged.enum = values;
return merged;
}
return existing;
}
type FlattenableVariantKey = "anyOf" | "oneOf";
type TopLevelConditionalKey = FlattenableVariantKey | "allOf";
function hasTopLevelArrayKeyword(
schemaRecord: Record<string, unknown>,
key: TopLevelConditionalKey,
): boolean {
return Array.isArray(schemaRecord[key]);
}
function getFlattenableVariantKey(
schemaRecord: Record<string, unknown>,
): FlattenableVariantKey | null {
if (hasTopLevelArrayKeyword(schemaRecord, "anyOf")) {
return "anyOf";
}
if (hasTopLevelArrayKeyword(schemaRecord, "oneOf")) {
return "oneOf";
}
return null;
}
function getTopLevelConditionalKey(
schemaRecord: Record<string, unknown>,
): TopLevelConditionalKey | null {
return (
getFlattenableVariantKey(schemaRecord) ??
(hasTopLevelArrayKeyword(schemaRecord, "allOf") ? "allOf" : null)
);
}
function hasTopLevelObjectSchema(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return "type" in schemaRecord && "properties" in schemaRecord && conditionalKey === null;
}
function isObjectLikeSchemaMissingType(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return (
!("type" in schemaRecord) &&
(typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) &&
conditionalKey === null
);
}
function isTypedSchemaMissingProperties(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return "type" in schemaRecord && !("properties" in schemaRecord) && conditionalKey === null;
}
function isTrulyEmptySchema(schemaRecord: Record<string, unknown>): boolean {
return Object.keys(schemaRecord).length === 0;
}
export function normalizeToolParameterSchema(
schema: unknown,
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
): unknown {
const schemaRecord =
schema && typeof schema === "object" ? (schema as Record<string, unknown>) : undefined;
if (!schemaRecord) {
return schema;
}
// Provider quirks:
// - Gemini rejects several JSON Schema keywords, so we scrub those.
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
// - Anthropic expects full JSON Schema draft 2020-12 compliance.
// - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright.
//
// Normalize once here so callers can always pass `tools` through unchanged.
const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider);
const isGeminiProvider =
normalizedProvider.includes("google") || normalizedProvider.includes("gemini");
const isAnthropicProvider = normalizedProvider.includes("anthropic");
const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat);
function applyProviderCleaning(s: unknown): unknown {
if (isGeminiProvider && !isAnthropicProvider) {
return cleanSchemaForGemini(s);
}
if (unsupportedToolSchemaKeywords.size > 0) {
return stripUnsupportedSchemaKeywords(s, unsupportedToolSchemaKeywords);
}
return s;
}
const conditionalKey = getTopLevelConditionalKey(schemaRecord);
const flattenableVariantKey = getFlattenableVariantKey(schemaRecord);
if (hasTopLevelObjectSchema(schemaRecord, conditionalKey)) {
return applyProviderCleaning(schemaRecord);
}
if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, type: "object" });
}
if (isTypedSchemaMissingProperties(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, properties: {} });
}
if (!flattenableVariantKey) {
if (isTrulyEmptySchema(schemaRecord)) {
// Handle the proven MCP no-parameter case: a truly empty schema object.
return applyProviderCleaning({ type: "object", properties: {} });
}
if (conditionalKey === "allOf") {
// Top-level `allOf` is not safely flattenable with the same heuristics we
// use for unions. Keep it explicit rather than silently rewriting it.
return schema;
}
return schema;
}
const variants = schemaRecord[flattenableVariantKey] as unknown[];
const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>();
let objectVariants = 0;
for (const entry of variants) {
if (!entry || typeof entry !== "object") {
continue;
}
const props = (entry as { properties?: unknown }).properties;
if (!props || typeof props !== "object") {
continue;
}
objectVariants += 1;
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
if (!(key in mergedProperties)) {
mergedProperties[key] = value;
continue;
}
mergedProperties[key] = mergePropertySchemas(mergedProperties[key], value);
}
const required = Array.isArray((entry as { required?: unknown }).required)
? (entry as { required: unknown[] }).required
: [];
for (const key of required) {
if (typeof key !== "string") {
continue;
}
requiredCounts.set(key, (requiredCounts.get(key) ?? 0) + 1);
}
}
const baseRequired = Array.isArray(schemaRecord.required)
? schemaRecord.required.filter((key) => typeof key === "string")
: undefined;
const mergedRequired =
baseRequired && baseRequired.length > 0
? baseRequired
: objectVariants > 0
? Array.from(requiredCounts.entries())
.filter(([, count]) => count === objectVariants)
.map(([key]) => key)
: undefined;
const nextSchema: Record<string, unknown> = { ...schemaRecord };
const flattenedSchema = {
type: "object",
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}),
properties:
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
additionalProperties:
"additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true,
};
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// - Anthropic accepts proper JSON Schema with constraints.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
return applyProviderCleaning(flattenedSchema);
}

View File

@@ -1,261 +1,16 @@
import type { ModelCompatConfig } from "../config/types.models.js";
import { stripUnsupportedSchemaKeywords } from "../plugin-sdk/provider-tools.js";
import { resolveUnsupportedToolSchemaKeywords } from "../plugins/provider-model-compat.js";
import { copyPluginToolMeta } from "../plugins/tools.js";
import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
import { copyChannelAgentToolMeta } from "./channel-tools.js";
import {
normalizeToolParameterSchema,
type ToolParameterSchemaOptions,
} from "./pi-tools-parameter-schema.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
import { cleanSchemaForGemini } from "./schema/clean-for-gemini.js";
function extractEnumValues(schema: unknown): unknown[] | undefined {
if (!schema || typeof schema !== "object") {
return undefined;
}
const record = schema as Record<string, unknown>;
if (Array.isArray(record.enum)) {
return record.enum;
}
if ("const" in record) {
return [record.const];
}
const variants = Array.isArray(record.anyOf)
? record.anyOf
: Array.isArray(record.oneOf)
? record.oneOf
: null;
if (variants) {
const values = variants.flatMap((variant) => {
const extracted = extractEnumValues(variant);
return extracted ?? [];
});
return values.length > 0 ? values : undefined;
}
return undefined;
}
function mergePropertySchemas(existing: unknown, incoming: unknown): unknown {
if (!existing) {
return incoming;
}
if (!incoming) {
return existing;
}
const existingEnum = extractEnumValues(existing);
const incomingEnum = extractEnumValues(incoming);
if (existingEnum || incomingEnum) {
const values = Array.from(new Set([...(existingEnum ?? []), ...(incomingEnum ?? [])]));
const merged: Record<string, unknown> = {};
for (const source of [existing, incoming]) {
if (!source || typeof source !== "object") {
continue;
}
const record = source as Record<string, unknown>;
for (const key of ["title", "description", "default"]) {
if (!(key in merged) && key in record) {
merged[key] = record[key];
}
}
}
const types = new Set(values.map((value) => typeof value));
if (types.size === 1) {
merged.type = Array.from(types)[0];
}
merged.enum = values;
return merged;
}
return existing;
}
type FlattenableVariantKey = "anyOf" | "oneOf";
type TopLevelConditionalKey = FlattenableVariantKey | "allOf";
function hasTopLevelArrayKeyword(
schemaRecord: Record<string, unknown>,
key: TopLevelConditionalKey,
): boolean {
return Array.isArray(schemaRecord[key]);
}
function getFlattenableVariantKey(
schemaRecord: Record<string, unknown>,
): FlattenableVariantKey | null {
if (hasTopLevelArrayKeyword(schemaRecord, "anyOf")) {
return "anyOf";
}
if (hasTopLevelArrayKeyword(schemaRecord, "oneOf")) {
return "oneOf";
}
return null;
}
function getTopLevelConditionalKey(
schemaRecord: Record<string, unknown>,
): TopLevelConditionalKey | null {
return (
getFlattenableVariantKey(schemaRecord) ??
(hasTopLevelArrayKeyword(schemaRecord, "allOf") ? "allOf" : null)
);
}
function hasTopLevelObjectSchema(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return "type" in schemaRecord && "properties" in schemaRecord && conditionalKey === null;
}
function isObjectLikeSchemaMissingType(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return (
!("type" in schemaRecord) &&
(typeof schemaRecord.properties === "object" || Array.isArray(schemaRecord.required)) &&
conditionalKey === null
);
}
function isTypedSchemaMissingProperties(
schemaRecord: Record<string, unknown>,
conditionalKey: TopLevelConditionalKey | null,
): boolean {
return "type" in schemaRecord && !("properties" in schemaRecord) && conditionalKey === null;
}
function isTrulyEmptySchema(schemaRecord: Record<string, unknown>): boolean {
return Object.keys(schemaRecord).length === 0;
}
export function normalizeToolParameterSchema(
schema: unknown,
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
): unknown {
const schemaRecord =
schema && typeof schema === "object" ? (schema as Record<string, unknown>) : undefined;
if (!schemaRecord) {
return schema;
}
// Provider quirks:
// - Gemini rejects several JSON Schema keywords, so we scrub those.
// - OpenAI rejects function tool schemas unless the *top-level* is `type: "object"`.
// (TypeBox root unions compile to `{ anyOf: [...] }` without `type`).
// - Anthropic expects full JSON Schema draft 2020-12 compliance.
// - xAI rejects validation-constraint keywords (minLength, maxLength, etc.) outright.
//
// Normalize once here so callers can always pass `tools` through unchanged.
const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider);
const isGeminiProvider =
normalizedProvider.includes("google") || normalizedProvider.includes("gemini");
const isAnthropicProvider = normalizedProvider.includes("anthropic");
const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat);
function applyProviderCleaning(s: unknown): unknown {
if (isGeminiProvider && !isAnthropicProvider) {
return cleanSchemaForGemini(s);
}
if (unsupportedToolSchemaKeywords.size > 0) {
return stripUnsupportedSchemaKeywords(s, unsupportedToolSchemaKeywords);
}
return s;
}
const conditionalKey = getTopLevelConditionalKey(schemaRecord);
const flattenableVariantKey = getFlattenableVariantKey(schemaRecord);
if (hasTopLevelObjectSchema(schemaRecord, conditionalKey)) {
return applyProviderCleaning(schemaRecord);
}
if (isObjectLikeSchemaMissingType(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, type: "object" });
}
if (isTypedSchemaMissingProperties(schemaRecord, conditionalKey)) {
return applyProviderCleaning({ ...schemaRecord, properties: {} });
}
if (!flattenableVariantKey) {
if (isTrulyEmptySchema(schemaRecord)) {
// Handle the proven MCP no-parameter case: a truly empty schema object.
return applyProviderCleaning({ type: "object", properties: {} });
}
if (conditionalKey === "allOf") {
// Top-level `allOf` is not safely flattenable with the same heuristics we
// use for unions. Keep it explicit rather than silently rewriting it.
return schema;
}
return schema;
}
const variants = schemaRecord[flattenableVariantKey] as unknown[];
const mergedProperties: Record<string, unknown> = {};
const requiredCounts = new Map<string, number>();
let objectVariants = 0;
for (const entry of variants) {
if (!entry || typeof entry !== "object") {
continue;
}
const props = (entry as { properties?: unknown }).properties;
if (!props || typeof props !== "object") {
continue;
}
objectVariants += 1;
for (const [key, value] of Object.entries(props as Record<string, unknown>)) {
if (!(key in mergedProperties)) {
mergedProperties[key] = value;
continue;
}
mergedProperties[key] = mergePropertySchemas(mergedProperties[key], value);
}
const required = Array.isArray((entry as { required?: unknown }).required)
? (entry as { required: unknown[] }).required
: [];
for (const key of required) {
if (typeof key !== "string") {
continue;
}
requiredCounts.set(key, (requiredCounts.get(key) ?? 0) + 1);
}
}
const baseRequired = Array.isArray(schemaRecord.required)
? schemaRecord.required.filter((key) => typeof key === "string")
: undefined;
const mergedRequired =
baseRequired && baseRequired.length > 0
? baseRequired
: objectVariants > 0
? Array.from(requiredCounts.entries())
.filter(([, count]) => count === objectVariants)
.map(([key]) => key)
: undefined;
const nextSchema: Record<string, unknown> = { ...schemaRecord };
const flattenedSchema = {
type: "object",
...(typeof nextSchema.title === "string" ? { title: nextSchema.title } : {}),
...(typeof nextSchema.description === "string" ? { description: nextSchema.description } : {}),
properties:
Object.keys(mergedProperties).length > 0 ? mergedProperties : (schemaRecord.properties ?? {}),
...(mergedRequired && mergedRequired.length > 0 ? { required: mergedRequired } : {}),
additionalProperties:
"additionalProperties" in schemaRecord ? schemaRecord.additionalProperties : true,
};
// Flatten union schemas into a single object schema:
// - Gemini doesn't allow top-level `type` together with `anyOf`.
// - OpenAI rejects schemas without top-level `type: "object"`.
// - Anthropic accepts proper JSON Schema with constraints.
// Merging properties preserves useful enums like `action` while keeping schemas portable.
return applyProviderCleaning(flattenedSchema);
}
export { normalizeToolParameterSchema };
export function normalizeToolParameters(
tool: AnyAgentTool,
options?: { modelProvider?: string; modelId?: string; modelCompat?: ModelCompatConfig },
options?: ToolParameterSchemaOptions,
): AnyAgentTool {
function preserveToolMeta(target: AnyAgentTool): AnyAgentTool {
copyPluginToolMeta(tool, target);
@@ -280,5 +35,5 @@ export function normalizeToolParameters(
* This function should only be used for Gemini providers.
*/
export function cleanToolSchemaForGemini(schema: Record<string, unknown>): unknown {
return cleanSchemaForGemini(schema);
return normalizeToolParameterSchema(schema, { modelProvider: "gemini" });
}

View File

@@ -7,8 +7,8 @@ import {
resolveConfiguredModelRef,
resolveModelRefFromString,
} from "../agents/model-selection.js";
import { resolveOpenAITextVerbosity } from "../agents/openai-text-verbosity.js";
import { resolveExtraParams } from "../agents/pi-embedded-runner/extra-params.js";
import { resolveOpenAITextVerbosity } from "../agents/pi-embedded-runner/openai-stream-wrappers.js";
import { resolveSandboxRuntimeStatus } from "../agents/sandbox.js";
import { describeToolForVerbose } from "../agents/tool-description-summary.js";
import { normalizeToolName } from "../agents/tool-policy-shared.js";