Skip to content

Commit 8c05154

Browse files
committed
fix(agents): clean Gemini tool schemas by model id
1 parent 5097749 commit 8c05154

3 files changed

Lines changed: 130 additions & 2 deletions

File tree

src/agents/agent-tools-parameter-schema.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,17 @@ function resolveToolParameterSchemaCacheKey(
2929
): string {
3030
const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider);
3131
const normalizedModelId = normalizeLowercaseStringOrEmpty(options?.modelId);
32+
const toolSchemaProfile = normalizeLowercaseStringOrEmpty(
33+
options?.modelCompat?.toolSchemaProfile,
34+
);
3235
const unsupportedKeywords = Array.from(
3336
resolveUnsupportedToolSchemaKeywords(options?.modelCompat),
3437
).toSorted();
3538
const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat);
3639
return JSON.stringify([
3740
normalizedProvider,
3841
normalizedModelId,
42+
toolSchemaProfile,
3943
unsupportedKeywords,
4044
omitEmptyArrayItems,
4145
]);
@@ -57,6 +61,10 @@ function rememberCachedToolParameterSchema(schema: object, key: string, value: T
5761
return value;
5862
}
5963

64+
function isGeminiModelId(modelId: string): boolean {
65+
return /(?:^|[/:])gemini(?:$|[-/:.])/.test(modelId);
66+
}
67+
6068
function extractEnumValues(schema: unknown): unknown[] | undefined {
6169
if (!schema || typeof schema !== "object") {
6270
return undefined;
@@ -769,8 +777,15 @@ function normalizeToolParameterSchemaUncached(
769777
//
770778
// Normalize once here so callers can always pass `tools` through unchanged.
771779
const normalizedProvider = normalizeLowercaseStringOrEmpty(options?.modelProvider);
780+
const normalizedModelId = normalizeLowercaseStringOrEmpty(options?.modelId);
781+
const normalizedToolSchemaProfile = normalizeLowercaseStringOrEmpty(
782+
options?.modelCompat?.toolSchemaProfile,
783+
);
772784
const isGeminiProvider =
773-
normalizedProvider.includes("google") || normalizedProvider.includes("gemini");
785+
normalizedProvider.includes("google") ||
786+
normalizedProvider.includes("gemini") ||
787+
isGeminiModelId(normalizedModelId) ||
788+
normalizedToolSchemaProfile === "gemini";
774789
const isAnthropicProvider = normalizedProvider.includes("anthropic");
775790
const unsupportedToolSchemaKeywords = resolveUnsupportedToolSchemaKeywords(options?.modelCompat);
776791
const omitEmptyArrayItems = shouldOmitEmptyArrayItems(options?.modelCompat);
@@ -781,7 +796,13 @@ function normalizeToolParameterSchemaUncached(
781796
? stripEmptyArrayItemsFromArraySchemas(normalizedSchema)
782797
: normalizedSchema;
783798
if (isGeminiProvider && !isAnthropicProvider) {
784-
return cleanSchemaForGemini(arrayItemsCompatibleSchema);
799+
const geminiCompatibleSchema = cleanSchemaForGemini(arrayItemsCompatibleSchema);
800+
return unsupportedToolSchemaKeywords.size > 0
801+
? (stripUnsupportedSchemaKeywords(
802+
geminiCompatibleSchema,
803+
unsupportedToolSchemaKeywords,
804+
) as TSchema)
805+
: geminiCompatibleSchema;
785806
}
786807
if (unsupportedToolSchemaKeywords.size > 0) {
787808
return stripUnsupportedSchemaKeywords(

src/agents/agent-tools.schema.test.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,93 @@ describe("normalizeToolParameterSchema", () => {
4242
expect(providerSpecific).toEqual(first);
4343
});
4444

45+
it("uses Gemini cleanup for OpenAI-compatible providers when the model id is Gemini", () => {
46+
const schema = {
47+
type: "object",
48+
properties: {
49+
sessionKey: {
50+
description: "Explicit session key, or null to clear it",
51+
anyOf: [{ type: "string" }, { type: "null" }],
52+
},
53+
},
54+
};
55+
56+
expect(
57+
normalizeToolParameterSchema(schema, {
58+
modelProvider: "jjcc",
59+
modelId: "gemini-3.1-pro-preview",
60+
}),
61+
).toEqual({
62+
type: "object",
63+
properties: {
64+
sessionKey: {
65+
type: "string",
66+
description: "Explicit session key, or null to clear it",
67+
},
68+
},
69+
});
70+
expect(
71+
normalizeToolParameterSchema(schema, {
72+
modelProvider: "stepfun",
73+
modelId: "step-router-v1",
74+
}),
75+
).toEqual(schema);
76+
});
77+
78+
it("keeps normalized tool-schema profile behavior aligned with the cache key", () => {
79+
const schema = {
80+
type: "object",
81+
properties: {
82+
sessionKey: {
83+
anyOf: [{ type: "string" }, { type: "null" }],
84+
},
85+
},
86+
};
87+
88+
const defaultSchema = normalizeToolParameterSchema(schema, {
89+
modelProvider: "openai-compatible",
90+
modelId: "custom-model",
91+
});
92+
const mixedCaseGeminiProfileSchema = normalizeToolParameterSchema(schema, {
93+
modelProvider: "openai-compatible",
94+
modelId: "custom-model",
95+
modelCompat: { toolSchemaProfile: "Gemini" },
96+
});
97+
98+
expect(defaultSchema).toEqual(schema);
99+
expect(mixedCaseGeminiProfileSchema).toEqual({
100+
type: "object",
101+
properties: {
102+
sessionKey: { type: "string" },
103+
},
104+
});
105+
});
106+
107+
it("applies explicit unsupported keyword stripping after Gemini cleanup", () => {
108+
expect(
109+
normalizeToolParameterSchema(
110+
{
111+
type: "object",
112+
properties: {
113+
count: {
114+
anyOf: [{ type: "integer", vendorOnly: true }, { type: "null" }],
115+
},
116+
},
117+
},
118+
{
119+
modelProvider: "jjcc",
120+
modelId: "gemini-3.1-pro-preview",
121+
modelCompat: { unsupportedToolSchemaKeywords: ["vendorOnly"] },
122+
},
123+
),
124+
).toEqual({
125+
type: "object",
126+
properties: {
127+
count: { type: "integer" },
128+
},
129+
});
130+
});
131+
45132
it("normalizes truly empty schemas to type:object with properties:{}", () => {
46133
expect(normalizeToolParameterSchema({})).toEqual({
47134
type: "object",

src/agents/tools/cron-tool.schema.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ describe("CronToolSchema", () => {
3333
const providerSchemaRecord = normalizeToolParameterSchema(createCronToolSchema(), {
3434
modelProvider: "gemini",
3535
}) as unknown as Record<string, unknown>;
36+
const jjccGeminiSchemaRecord = normalizeToolParameterSchema(createCronToolSchema(), {
37+
modelProvider: "jjcc",
38+
modelId: "gemini-3.1-pro-preview",
39+
}) as unknown as Record<string, unknown>;
3640

3741
// Regression: models like GPT-5.4 rely on these fields to populate job/patch.
3842
// If a field is removed from this list the test must be updated intentionally.
@@ -254,6 +258,22 @@ describe("CronToolSchema", () => {
254258
expect(patchProps?.payload?.properties?.model?.description).toMatch(/null to clear/i);
255259
});
256260

261+
it("projects nullable cron fields for Gemini models behind OpenAI-compatible providers", () => {
262+
expect(propertyAt(jjccGeminiSchemaRecord, "job.agentId")).toMatchObject({
263+
type: "string",
264+
});
265+
expect(propertyAt(jjccGeminiSchemaRecord, "job.sessionKey")).toMatchObject({
266+
type: "string",
267+
});
268+
expect(propertyAt(jjccGeminiSchemaRecord, "patch.payload.toolsAllow")).toMatchObject({
269+
type: "array",
270+
});
271+
expect(propertyAt(jjccGeminiSchemaRecord, "patch.delivery.channel")).toMatchObject({
272+
type: "string",
273+
});
274+
expect(JSON.stringify(jjccGeminiSchemaRecord)).not.toContain('"anyOf"');
275+
});
276+
257277
// Regression guard: ensure no OpenAPI 3.0 incompatible keywords leak into the
258278
// serialized provider-facing cron tool schema.
259279
it("serialized provider schema contains no type-array or not/const keywords", () => {

0 commit comments

Comments
 (0)