Skip to content

Commit e097f30

Browse files
committed
fix(gemini): INVALID_ARGUMENT when loaded too many MCPs
1 parent a91881b commit e097f30

5 files changed

Lines changed: 399 additions & 51 deletions

File tree

apps/vscode-e2e/src/suite/providers/gemini.test.ts

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,68 @@ import { waitUntilCompleted } from "../utils"
88
const GEMINI_API_KEY = process.env.GEMINI_API_KEY ?? process.env.GOOGLE_API_KEY
99
const GEMINI_MODEL_ID = "gemini-3.1-pro-preview"
1010

11+
type FunctionDeclaration = {
12+
name: string
13+
parametersJsonSchema?: Record<string, unknown>
14+
}
15+
16+
type GeminiToolConfig = {
17+
functionCallingConfig?: {
18+
mode?: string
19+
allowedFunctionNames?: string[]
20+
}
21+
}
22+
1123
type CapturedGeminiRequest = {
1224
model?: string
1325
lastUserMessage: string
1426
thinkingConfig?: Record<string, unknown>
27+
toolConfig?: GeminiToolConfig
1528
hasTools: boolean
1629
toolDeclarationCount: number
30+
functionDeclarations: FunctionDeclaration[]
31+
}
32+
33+
function findInvalidSchemaPatterns(schema: unknown, path = ""): string[] {
34+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) {
35+
return []
36+
}
37+
38+
const obj = schema as Record<string, unknown>
39+
const violations: string[] = []
40+
41+
if ("additionalProperties" in obj) {
42+
violations.push(`${path}.additionalProperties (stripped for Gemini compatibility)`)
43+
}
44+
45+
if ("default" in obj) {
46+
violations.push(`${path}.default (stripped for Gemini compatibility)`)
47+
}
48+
49+
if ("$schema" in obj) {
50+
violations.push(`${path}.$schema (JSON Schema metadata stripped for Gemini compatibility)`)
51+
}
52+
53+
if ("type" in obj && Array.isArray(obj.type)) {
54+
violations.push(`${path}.type is an array ${JSON.stringify(obj.type)} (Gemini requires a single string type)`)
55+
}
56+
57+
for (const [key, value] of Object.entries(obj)) {
58+
if (key === "properties" && value && typeof value === "object") {
59+
for (const [propName, propSchema] of Object.entries(value as Record<string, unknown>)) {
60+
violations.push(...findInvalidSchemaPatterns(propSchema, `${path}.properties.${propName}`))
61+
}
62+
} else if (key === "items") {
63+
violations.push(...findInvalidSchemaPatterns(value, `${path}.items`))
64+
} else if (key === "anyOf" || key === "oneOf" || key === "allOf") {
65+
violations.push(`${path}.${key} (collapsed for Gemini compatibility)`)
66+
if (Array.isArray(value)) {
67+
value.forEach((item, i) => violations.push(...findInvalidSchemaPatterns(item, `${path}.${key}[${i}]`)))
68+
}
69+
}
70+
}
71+
72+
return violations
1773
}
1874

1975
function getRequestUrl(input: RequestInfo | URL): string {
@@ -74,9 +130,10 @@ function installGeminiRequestCapture(capture: CapturedGeminiRequest[], baseUrl:
74130
if (isUrlWithOrigin(url, targetOrigin) && isGeminiGenerateContentUrl(url)) {
75131
const body = init?.body && typeof init.body === "string" ? JSON.parse(init.body) : {}
76132
const tools = Array.isArray(body.tools) ? body.tools : []
77-
const toolDeclarationCount = tools.reduce((count: number, tool: { functionDeclarations?: unknown[] }) => {
78-
return count + (Array.isArray(tool.functionDeclarations) ? tool.functionDeclarations.length : 0)
79-
}, 0)
133+
const functionDeclarations: FunctionDeclaration[] = tools.flatMap(
134+
(tool: { functionDeclarations?: FunctionDeclaration[] }) =>
135+
Array.isArray(tool.functionDeclarations) ? tool.functionDeclarations : [],
136+
)
80137

81138
capture.push({
82139
model: extractGeminiModel(url),
@@ -85,8 +142,13 @@ function installGeminiRequestCapture(capture: CapturedGeminiRequest[], baseUrl:
85142
body.generationConfig && typeof body.generationConfig === "object"
86143
? (body.generationConfig.thinkingConfig as Record<string, unknown> | undefined)
87144
: undefined,
145+
toolConfig:
146+
body.toolConfig && typeof body.toolConfig === "object"
147+
? (body.toolConfig as GeminiToolConfig)
148+
: undefined,
88149
hasTools: tools.length > 0,
89-
toolDeclarationCount,
150+
toolDeclarationCount: functionDeclarations.length,
151+
functionDeclarations,
90152
})
91153
}
92154

@@ -105,7 +167,8 @@ suite("Gemini provider", function () {
105167
const requests: CapturedGeminiRequest[] = []
106168

107169
setup(function () {
108-
if (!process.env.AIMOCK_URL && !GEMINI_API_KEY) {
170+
const isReplay = process.env.AIMOCK_URL && process.env.AIMOCK_RECORD !== "true"
171+
if (!isReplay && !GEMINI_API_KEY) {
109172
this.skip()
110173
}
111174
})
@@ -131,23 +194,21 @@ suite("Gemini provider", function () {
131194
})
132195
})
133196

134-
for (const reasoningEnabled of [true, false] as const) {
135-
test(`Should complete a task end-to-end using ${GEMINI_MODEL_ID} via Gemini provider with reasoning ${
136-
reasoningEnabled ? "enabled" : "disabled"
137-
}`, async () => {
197+
for (const reasoningEffort of ["high", "low", "disable"] as const) {
198+
test(`Should complete a task end-to-end using ${GEMINI_MODEL_ID} via Gemini provider with reasoning effort "${reasoningEffort}"`, async () => {
138199
requests.length = 0
139200

140201
const api = globalThis.api
141202
const aimockUrl = process.env.AIMOCK_URL
142203
const isRecord = process.env.AIMOCK_RECORD === "true"
143-
const promptTag = reasoningEnabled ? "gemini-e2e:reasoning-on" : "gemini-e2e:reasoning-off"
204+
const promptTag = `gemini-e2e:reasoning-${reasoningEffort}`
144205

145206
await api.setConfiguration({
146207
apiProvider: "gemini" as const,
147208
geminiApiKey: aimockUrl && !isRecord ? "mock-key" : GEMINI_API_KEY!,
148209
apiModelId: GEMINI_MODEL_ID,
149-
enableReasoningEffort: reasoningEnabled,
150-
reasoningEffort: reasoningEnabled ? ("high" as const) : ("disable" as const),
210+
enableReasoningEffort: reasoningEffort !== "disable",
211+
reasoningEffort: reasoningEffort,
151212
...(aimockUrl && { googleGeminiBaseUrl: aimockUrl }),
152213
})
153214

@@ -179,18 +240,39 @@ suite("Gemini provider", function () {
179240
firstRequest.toolDeclarationCount > 0,
180241
"Gemini provider should declare at least one callable tool",
181242
)
243+
assert.strictEqual(
244+
firstRequest.toolConfig?.functionCallingConfig?.allowedFunctionNames,
245+
undefined,
246+
"Gemini requests should not send allowedFunctionNames; the Gemini backend returns generic INVALID_ARGUMENT for larger or history-incompatible restriction lists",
247+
)
182248

183-
if (reasoningEnabled) {
184-
assert.ok(
185-
firstRequest.thinkingConfig,
186-
"Reasoning-enabled Gemini requests should include thinkingConfig",
249+
// Verify tool schemas are sanitized for Gemini compatibility. Gemini documents
250+
// function declaration schemas as a selected OpenAPI-style subset with
251+
// single-value `type` plus `nullable`; live testing also showed opaque
252+
// INVALID_ARGUMENT failures from broader third-party MCP schema metadata.
253+
for (const decl of firstRequest.functionDeclarations) {
254+
const violations = findInvalidSchemaPatterns(
255+
decl.parametersJsonSchema,
256+
`${decl.name}.parametersJsonSchema`,
187257
)
188-
} else {
258+
assert.strictEqual(
259+
violations.length,
260+
0,
261+
`Tool "${decl.name}" has Gemini-incompatible schema: ${violations.join("; ")}`,
262+
)
263+
}
264+
265+
if (reasoningEffort === "disable") {
189266
assert.strictEqual(
190267
firstRequest.thinkingConfig,
191268
undefined,
192269
"Reasoning-disabled Gemini requests should omit thinkingConfig",
193270
)
271+
} else {
272+
assert.ok(
273+
firstRequest.thinkingConfig,
274+
`Gemini requests with reasoningEffort="${reasoningEffort}" should include thinkingConfig`,
275+
)
194276
}
195277

196278
const completionMessage = messages.find(

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"vsix:nightly": "turbo vsix:nightly --log-order grouped --output-logs new-only",
2222
"clean": "turbo clean --log-order grouped --output-logs new-only && rimraf dist out bin .vite-port .turbo",
2323
"install:vsix": "pnpm install --frozen-lockfile && pnpm clean && pnpm vsix && node scripts/install-vsix.js",
24+
"install:vsix:force": "pnpm install --frozen-lockfile && pnpm clean && turbo bundle --force && turbo vsix --force && node scripts/install-vsix.js",
2425
"install:vsix:nightly": "pnpm install --frozen-lockfile && pnpm clean && pnpm vsix:nightly && node scripts/install-vsix.js --nightly",
2526
"code-server:install": "node scripts/code-server.js",
2627
"changeset:version": "cp CHANGELOG.md src/CHANGELOG.md && changeset version && cp -vf src/CHANGELOG.md .",

0 commit comments

Comments
 (0)