mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:40:44 +00:00
perf(test): slim OpenAI and Pi runner imports
This commit is contained in:
54
src/agents/openai-strict-tool-setting.ts
Normal file
54
src/agents/openai-strict-tool-setting.ts
Normal 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;
|
||||
}
|
||||
27
src/agents/openai-text-verbosity.ts
Normal file
27
src/agents/openai-text-verbosity.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
257
src/agents/pi-tools-parameter-schema.ts
Normal file
257
src/agents/pi-tools-parameter-schema.ts
Normal 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);
|
||||
}
|
||||
@@ -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" });
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user