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

2
.vscode/launch.json vendored
View File

@@ -50,7 +50,7 @@
"type": "node", "type": "node",
// fix source mapping when debugging in sandbox using global installation // fix source mapping when debugging in sandbox using global installation
// note this does not interfere when remoteRoot is also ${workspaceFolder}/packages // note this does not interfere when remoteRoot is also ${workspaceFolder}/packages
"remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@gemini-cli", "remoteRoot": "/usr/local/share/npm-global/lib/node_modules/@qwen-code",
"localRoot": "${workspaceFolder}/packages" "localRoot": "${workspaceFolder}/packages"
}, },
{ {

View File

@@ -121,7 +121,7 @@ export class TestRig {
mkdirSync(this.testDir, { recursive: true }); mkdirSync(this.testDir, { recursive: true });
// Create a settings file to point the CLI to the local collector // Create a settings file to point the CLI to the local collector
const geminiDir = join(this.testDir, '.gemini'); const geminiDir = join(this.testDir, '.qwen');
mkdirSync(geminiDir, { recursive: true }); mkdirSync(geminiDir, { recursive: true });
// In sandbox mode, use an absolute path for telemetry inside the container // In sandbox mode, use an absolute path for telemetry inside the container
// The container mounts the test directory at the same path as the host // The container mounts the test directory at the same path as the host
@@ -567,7 +567,7 @@ export class TestRig {
// Look for tool call logs // Look for tool call logs
if ( if (
logData.attributes && logData.attributes &&
logData.attributes['event.name'] === 'gemini_cli.tool_call' logData.attributes['event.name'] === 'qwen-code.tool_call'
) { ) {
const toolName = logData.attributes.function_name; const toolName = logData.attributes.function_name;
logs.push({ logs.push({

View File

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

View File

@@ -13,6 +13,8 @@ import {
Content, Content,
Tool, Tool,
GenerateContentResponse, GenerateContentResponse,
FunctionDeclaration,
Schema,
} from '@google/genai'; } from '@google/genai';
import { getFolderStructure } from '../utils/getFolderStructure.js'; import { getFolderStructure } from '../utils/getFolderStructure.js';
import { import {
@@ -25,7 +27,7 @@ import { Config } from '../config/config.js';
import { UserTierId } from '../code_assist/types.js'; import { UserTierId } from '../code_assist/types.js';
import { getCoreSystemPrompt, getCompressionPrompt } from './prompts.js'; import { getCoreSystemPrompt, getCompressionPrompt } from './prompts.js';
import { ReadManyFilesTool } from '../tools/read-many-files.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 { checkNextSpeaker } from '../utils/nextSpeakerChecker.js';
import { reportError } from '../utils/errorReporting.js'; import { reportError } from '../utils/errorReporting.js';
import { GeminiChat } from './geminiChat.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 { LoopDetectionService } from '../services/loopDetectionService.js';
import { ideContext } from '../ide/ideContext.js'; import { ideContext } from '../ide/ideContext.js';
import { logNextSpeakerCheck } from '../telemetry/loggers.js'; import { logNextSpeakerCheck } from '../telemetry/loggers.js';
import { import { NextSpeakerCheckEvent } from '../telemetry/types.js';
MalformedJsonResponseEvent,
NextSpeakerCheckEvent,
} from '../telemetry/types.js';
import { ClearcutLogger } from '../telemetry/clearcut-logger/clearcut-logger.js';
function isThinkingSupported(model: string) { function isThinkingSupported(model: string) {
if (model.startsWith('gemini-2.5')) return true; if (model.startsWith('gemini-2.5')) return true;
@@ -539,6 +537,19 @@ export class GeminiClient {
...config, ...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 = () => const apiCall = () =>
this.getContentGenerator().generateContent( this.getContentGenerator().generateContent(
{ {
@@ -546,8 +557,7 @@ export class GeminiClient {
config: { config: {
...requestConfig, ...requestConfig,
systemInstruction, systemInstruction,
responseSchema: schema, tools,
responseMimeType: 'application/json',
}, },
contents, contents,
}, },
@@ -559,73 +569,16 @@ export class GeminiClient {
await this.handleFlashFallback(authType, error), await this.handleFlashFallback(authType, error),
authType: this.config.getContentGeneratorConfig()?.authType, authType: this.config.getContentGeneratorConfig()?.authType,
}); });
const functionCalls = getFunctionCalls(result);
let text = getResponseText(result); if (functionCalls && functionCalls.length > 0) {
if (!text) { const functionCall = functionCalls.find(
const error = new Error( (call) => call.name === 'respond_in_schema',
'API returned an empty response for generateJson.',
); );
await reportError( if (functionCall && functionCall.args) {
error, return functionCall.args as Record<string, unknown>;
'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 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) { } catch (error) {
if (abortSignal.aborted) { if (abortSignal.aborted) {
throw error; throw error;