diff --git a/.vscode/launch.json b/.vscode/launch.json index 9b9d150d..496e7233 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -50,7 +50,7 @@ "type": "node", // fix source mapping when debugging in sandbox using global installation // 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" }, { diff --git a/integration-tests/test-helper.js b/integration-tests/test-helper.js index 9526ea5f..6556c5c3 100644 --- a/integration-tests/test-helper.js +++ b/integration-tests/test-helper.js @@ -121,7 +121,7 @@ export class TestRig { mkdirSync(this.testDir, { recursive: true }); // 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 }); // 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 @@ -567,7 +567,7 @@ export class TestRig { // Look for tool call logs if ( logData.attributes && - logData.attributes['event.name'] === 'gemini_cli.tool_call' + logData.attributes['event.name'] === 'qwen-code.tool_call' ) { const toolName = logData.attributes.function_name; logs.push({ diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index 0af01726..98c4965c 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -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, }, diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index e70093d8..13f56ac1 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -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; } - - // 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;