fix generateJson with respond in schema

Co-Authored-By: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Yiheng Xu
2025-08-06 16:20:58 +08:00
parent 9ffeacc0f9
commit 14a3be7976
4 changed files with 74 additions and 82 deletions

View File

@@ -66,6 +66,17 @@ vi.mock('../utils/generateContentResponseUtilities', () => ({
getResponseText: (result: GenerateContentResponse) =>
result.candidates?.[0]?.content?.parts?.map((part) => part.text).join('') ||
undefined,
getFunctionCalls: (result: GenerateContentResponse) => {
// Extract function calls from the response
const parts = result.candidates?.[0]?.content?.parts;
if (!parts) {
return undefined;
}
const functionCallParts = parts
.filter((part) => !!part.functionCall)
.map((part) => part.functionCall);
return functionCallParts.length > 0 ? functionCallParts : undefined;
},
}));
vi.mock('../telemetry/index.js', () => ({
logApiRequest: vi.fn(),
@@ -158,7 +169,14 @@ describe('Gemini Client (client.ts)', () => {
candidates: [
{
content: {
parts: [{ text: '{"key": "value"}' }],
parts: [
{
functionCall: {
name: 'respond_in_schema',
args: { key: 'value' },
},
},
],
},
},
],
@@ -209,6 +227,7 @@ describe('Gemini Client (client.ts)', () => {
}),
getGeminiClient: vi.fn(),
setFallbackMode: vi.fn(),
getDebugMode: vi.fn().mockReturnValue(false),
};
const MockedConfig = vi.mocked(Config, true);
MockedConfig.mockImplementation(
@@ -387,7 +406,8 @@ describe('Gemini Client (client.ts)', () => {
};
client['contentGenerator'] = mockGenerator as ContentGenerator;
await client.generateJson(contents, schema, abortSignal);
const result = await client.generateJson(contents, schema, abortSignal);
expect(result).toEqual({ key: 'value' });
expect(mockGenerateContentFn).toHaveBeenCalledWith(
{
@@ -397,8 +417,17 @@ describe('Gemini Client (client.ts)', () => {
systemInstruction: getCoreSystemPrompt(''),
temperature: 0,
topP: 1,
responseSchema: schema,
responseMimeType: 'application/json',
tools: [
{
functionDeclarations: [
{
name: 'respond_in_schema',
description: 'Provide the response in provided schema',
parameters: schema,
},
],
},
],
},
contents,
},
@@ -419,13 +448,14 @@ describe('Gemini Client (client.ts)', () => {
};
client['contentGenerator'] = mockGenerator as ContentGenerator;
await client.generateJson(
const result = await client.generateJson(
contents,
schema,
abortSignal,
customModel,
customConfig,
);
expect(result).toEqual({ key: 'value' });
expect(mockGenerateContentFn).toHaveBeenCalledWith(
{
@@ -436,8 +466,17 @@ describe('Gemini Client (client.ts)', () => {
temperature: 0.9,
topP: 1, // from default
topK: 20,
responseSchema: schema,
responseMimeType: 'application/json',
tools: [
{
functionDeclarations: [
{
name: 'respond_in_schema',
description: 'Provide the response in provided schema',
parameters: schema,
},
],
},
],
},
contents,
},

View File

@@ -13,6 +13,8 @@ import {
Content,
Tool,
GenerateContentResponse,
FunctionDeclaration,
Schema,
} from '@google/genai';
import { getFolderStructure } from '../utils/getFolderStructure.js';
import {
@@ -25,7 +27,7 @@ import { Config } from '../config/config.js';
import { UserTierId } from '../code_assist/types.js';
import { getCoreSystemPrompt, getCompressionPrompt } from './prompts.js';
import { ReadManyFilesTool } from '../tools/read-many-files.js';
import { getResponseText } from '../utils/generateContentResponseUtilities.js';
import { getFunctionCalls } from '../utils/generateContentResponseUtilities.js';
import { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.js';
@@ -44,11 +46,7 @@ import { DEFAULT_GEMINI_FLASH_MODEL } from '../config/models.js';
import { LoopDetectionService } from '../services/loopDetectionService.js';
import { ideContext } from '../ide/ideContext.js';
import { logNextSpeakerCheck } from '../telemetry/loggers.js';
import {
MalformedJsonResponseEvent,
NextSpeakerCheckEvent,
} from '../telemetry/types.js';
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
import { NextSpeakerCheckEvent } from '../telemetry/types.js';
function isThinkingSupported(model: string) {
if (model.startsWith('gemini-2.5')) return true;
@@ -539,6 +537,19 @@ export class GeminiClient {
...config,
};
// Convert schema to function declaration
const functionDeclaration: FunctionDeclaration = {
name: 'respond_in_schema',
description: 'Provide the response in provided schema',
parameters: schema as Schema,
};
const tools: Tool[] = [
{
functionDeclarations: [functionDeclaration],
},
];
const apiCall = () =>
this.getContentGenerator().generateContent(
{
@@ -546,8 +557,7 @@ export class GeminiClient {
config: {
...requestConfig,
systemInstruction,
responseSchema: schema,
responseMimeType: 'application/json',
tools,
},
contents,
},
@@ -559,73 +569,16 @@ export class GeminiClient {
await this.handleFlashFallback(authType, error),
authType: this.config.getContentGeneratorConfig()?.authType,
});
let text = getResponseText(result);
if (!text) {
const error = new Error(
'API returned an empty response for generateJson.',
const functionCalls = getFunctionCalls(result);
if (functionCalls && functionCalls.length > 0) {
const functionCall = functionCalls.find(
(call) => call.name === 'respond_in_schema',
);
await reportError(
error,
'Error in generateJson: API returned an empty response.',
contents,
'generateJson-empty-response',
);
throw error;
}
const prefix = '```json';
const suffix = '```';
if (text.startsWith(prefix) && text.endsWith(suffix)) {
ClearcutLogger.getInstance(this.config)?.logMalformedJsonResponseEvent(
new MalformedJsonResponseEvent(modelToUse),
);
text = text
.substring(prefix.length, text.length - suffix.length)
.trim();
}
try {
// Try to extract JSON from various formats
const extractors = [
// Match ```json ... ``` or ``` ... ``` blocks
/```(?:json)?\s*\n?([\s\S]*?)\n?```/,
// Match inline code blocks `{...}`
/`(\{[\s\S]*?\})`/,
// Match raw JSON objects or arrays
/(\{[\s\S]*\}|\[[\s\S]*\])/,
];
for (const regex of extractors) {
const match = text.match(regex);
if (match && match[1]) {
try {
return JSON.parse(match[1].trim());
} catch {
// Continue to next pattern if parsing fails
continue;
}
}
if (functionCall && functionCall.args) {
return functionCall.args as Record<string, unknown>;
}
// If no patterns matched, try parsing the entire text
return JSON.parse(text.trim());
} catch (parseError) {
await reportError(
parseError,
'Failed to parse JSON response from generateJson.',
{
responseTextFailedToParse: text,
originalRequestContents: contents,
},
'generateJson-parse',
);
throw new Error(
`Failed to parse API response as JSON: ${getErrorMessage(
parseError,
)}`,
);
}
return {};
} catch (error) {
if (abortSignal.aborted) {
throw error;