diff --git a/.github/ISSUE_TEMPLATE/tool_call_error_cn.yml b/.github/ISSUE_TEMPLATE/tool_call_error_cn.yml index e89b258..33bb0d3 100644 --- a/.github/ISSUE_TEMPLATE/tool_call_error_cn.yml +++ b/.github/ISSUE_TEMPLATE/tool_call_error_cn.yml @@ -1,21 +1,21 @@ -name: 工具调用错误报告 -description: 报告工具调用相关的错误 (中文) -title: "[工具错误]: " +name: 工具调用/结构化输出报告 +description: 报告工具调用或结构化输出相关的错误 (中文) +title: "[特性错误]: " labels: ["🐛 Bug"] body: - type: markdown attributes: value: | - 如果您遇到了工具调用(Tool Calls)相关的错误,请务必按照以下格式提交一个新的 Issue,以便我们能够快速定位和解决问题。 + 如果您遇到了**工具调用(Tool Calls)**或**结构化输出(Structured Output/JSON Schema)**相关的错误,请务必按照以下格式提交一个新的 Issue,以便我们能够快速定位和解决问题。 - **请注意:** 为了排查工具调用问题,我们需要您提供详细的上下文日志。请在复现问题时开启调试模式,并提供完整的请求和响应日志。 + **请注意:** 为了排查此类问题,我们需要您提供详细的上下文日志。请在复现问题时开启调试模式,并提供完整的请求和响应日志。 - type: input id: version attributes: label: 版本号 description: 您当前使用的版本 - placeholder: "例如: v0.2.11" + placeholder: "例如: v0.3.0" validations: required: true @@ -49,7 +49,7 @@ body: id: problem_description attributes: label: 问题描述 - description: "请简要描述您遇到的错误现象,例如:请求成功但参数解析错误、返回 400 错误等" + description: "请简要描述您遇到的错误现象,例如:请求成功但参数解析错误、Schema 校验失败、返回 400 错误等" placeholder: 描述错误的具体表现... validations: required: true diff --git a/.github/ISSUE_TEMPLATE/tool_call_error_en.yml b/.github/ISSUE_TEMPLATE/tool_call_error_en.yml index 4be1da7..1cb5bf3 100644 --- a/.github/ISSUE_TEMPLATE/tool_call_error_en.yml +++ b/.github/ISSUE_TEMPLATE/tool_call_error_en.yml @@ -1,21 +1,21 @@ -name: Tool Call Error Report -description: Report an error related to tool calls (English) -title: "[Tool Error]: " +name: Tool Call / Structured Output Report +description: Report errors related to tool calls or structured outputs (English) +title: "[Feature Error]: " labels: ["🐛 Bug"] body: - type: markdown attributes: value: | - If you encounter errors related to Tool Calls, please submit a new issue using the format below so that we can quickly identify and resolve the problem. + If you encounter errors related to **Tool Calls** or **Structured Output (JSON Schema)**, please submit a new issue using the format below so that we can quickly identify and resolve the problem. - **Note:** To troubleshoot tool call issues, we need detailed context logs. Please enable debug mode when reproducing the issue and provide full request and response logs. + **Note:** To troubleshoot such issues, we need detailed context logs. Please enable debug mode when reproducing the issue and provide full request and response logs. - type: input id: version attributes: label: Version description: The version you are currently using - placeholder: "e.g., v0.2.11" + placeholder: "e.g., v0.3.0" validations: required: true @@ -49,7 +49,7 @@ body: id: problem_description attributes: label: Problem Description - description: "Briefly describe the error you encountered (e.g., request succeeded but parameter parsing failed, returned 400 error, etc.)" + description: "Briefly describe the error you encountered (e.g., request succeeded but parameter parsing failed, Schema validation error, returned 400 error, etc.)" placeholder: Describe the error details... validations: required: true diff --git a/src/core/FormatConverter.js b/src/core/FormatConverter.js index bd8e3fe..164baae 100644 --- a/src/core/FormatConverter.js +++ b/src/core/FormatConverter.js @@ -498,63 +498,97 @@ class FormatConverter { googleRequest.generationConfig = generationConfig; - // Convert OpenAI tools to Gemini functionDeclarations - const openaiTools = openaiBody.tools || openaiBody.functions; - if (openaiTools && Array.isArray(openaiTools) && openaiTools.length > 0) { - const functionDeclarations = []; + /** + * Helper function to convert OpenAI parameter types to Gemini format (uppercase). + * Also handles nullable types like ["string", "null"] -> type: "STRING", nullable: true. + * This function is used for both tools and response_format schema conversion. + * + * @param {Object} obj - The schema object to convert + * @param {boolean} [isResponseSchema=false] - If true, applies stricter rules (e.g. anyOf for unions) for Structured Outputs + * @returns {Object} The converted schema + */ + const convertParameterTypes = (obj, isResponseSchema = false) => { + if (!obj || typeof obj !== "object") return obj; - // Helper function to convert OpenAI parameter types to Gemini format (uppercase) - // Also handles nullable types like ["string", "null"] -> type: "STRING", nullable: true - const convertParameterTypes = obj => { - if (!obj || typeof obj !== "object") return obj; + const result = Array.isArray(obj) ? [] : {}; - const result = Array.isArray(obj) ? [] : {}; + for (const key of Object.keys(obj)) { + // 1. Filter out unsupported fields using a blacklist approach + // This allows potentially valid fields to pass through while blocking known problematic ones + const unsupportedKeys = ["$schema", "additionalProperties"]; - for (const key of Object.keys(obj)) { - // Skip fields not supported by Gemini API - // Gemini only supports: type, description, enum, items, properties, required, nullable - if (key === "$schema" || key === "additionalProperties") { - continue; - } + if (isResponseSchema) { + // For Structured Outputs: stricter filtering of metadata that causes 400 errors + unsupportedKeys.push("title", "default", "examples", "$defs", "id", "patternProperties"); + } - if (key === "type") { - if (Array.isArray(obj[key])) { - // Handle nullable types like ["string", "null"] - const types = obj[key]; - const nonNullTypes = types.filter(t => t !== "null"); - const hasNull = types.includes("null"); + if (unsupportedKeys.includes(key)) { + continue; + } - if (hasNull) { - result.nullable = true; - } + if (key === "type") { + if (Array.isArray(obj[key])) { + // Handle nullable types like ["string", "null"] + const types = obj[key]; + const nonNullTypes = types.filter(t => t !== "null"); + const hasNull = types.includes("null"); - if (nonNullTypes.length === 1) { - // Single non-null type: use it directly - result[key] = nonNullTypes[0].toUpperCase(); - } else if (nonNullTypes.length > 1) { - // Multiple non-null types: keep as array (uppercase) - result[key] = nonNullTypes.map(t => t.toUpperCase()); + if (hasNull) { + result.nullable = true; + } + + if (nonNullTypes.length === 1) { + // Single non-null type: use it directly + result[key] = nonNullTypes[0].toUpperCase(); + } else if (nonNullTypes.length > 1) { + // Multiple non-null types: e.g. ["string", "integer"] + if (isResponseSchema) { + // For Response Schema: Gemini doesn't support array types, use anyOf + result.anyOf = nonNullTypes.map(t => ({ + type: t.toUpperCase(), + })); } else { - // Only null type, default to STRING - result[key] = "STRING"; + result[key] = nonNullTypes.map(t => t.toUpperCase()); } - } else if (typeof obj[key] === "string") { - // Convert lowercase type to uppercase for Gemini - result[key] = obj[key].toUpperCase(); - } else if (typeof obj[key] === "object" && obj[key] !== null) { - result[key] = convertParameterTypes(obj[key]); } else { - result[key] = obj[key]; + // Only null type, default to STRING + result[key] = "STRING"; } + } else if (typeof obj[key] === "string") { + // Convert lowercase type to uppercase for Gemini + result[key] = obj[key].toUpperCase(); } else if (typeof obj[key] === "object" && obj[key] !== null) { - result[key] = convertParameterTypes(obj[key]); + result[key] = convertParameterTypes(obj[key], isResponseSchema); + } else { + result[key] = obj[key]; + } + } else if (key === "enum") { + // 2. Ensure all enum values are strings (Only for Response Schema) + if (isResponseSchema) { + if (Array.isArray(obj[key])) { + result[key] = obj[key].map(String); + } else if (obj[key] !== undefined && obj[key] !== null) { + result[key] = [String(obj[key])]; + } + result["type"] = "STRING"; } else { + // For Tools: Allow original enum values result[key] = obj[key]; } + } else if (typeof obj[key] === "object" && obj[key] !== null) { + result[key] = convertParameterTypes(obj[key], isResponseSchema); + } else { + result[key] = obj[key]; } + } - return result; - }; + return result; + }; + + // Convert OpenAI tools to Gemini functionDeclarations + const openaiTools = openaiBody.tools || openaiBody.functions; + if (openaiTools && Array.isArray(openaiTools) && openaiTools.length > 0) { + const functionDeclarations = []; for (const tool of openaiTools) { // Handle OpenAI tools format: { type: "function", function: {...} } @@ -572,7 +606,8 @@ class FormatConverter { if (funcDef.parameters) { // Convert parameter types from lowercase to uppercase - declaration.parameters = convertParameterTypes(funcDef.parameters); + // isResponseSchema = false for Tools + declaration.parameters = convertParameterTypes(funcDef.parameters, false); } functionDeclarations.push(declaration); @@ -619,6 +654,60 @@ class FormatConverter { } } + // Handle response_format for structured output + // Convert OpenAI response_format to Gemini responseSchema + const responseFormat = openaiBody.response_format; + if (responseFormat) { + if (responseFormat.type === "json_schema" && responseFormat.json_schema) { + // Extract schema from OpenAI format + const jsonSchema = responseFormat.json_schema; + const schema = jsonSchema.schema; + + if (schema) { + try { + this.logger.debug(`[Adapter] Debug: Converting OpenAI JSON Schema: ${JSON.stringify(schema)}`); + + // Convert schema to Gemini format (reuse convertParameterTypes helper defined above) + // isResponseSchema = true for Structured Output + const convertedSchema = convertParameterTypes(schema, true); + + this.logger.debug( + `[Adapter] Debug: Converted Gemini JSON Schema: ${JSON.stringify(convertedSchema)}` + ); + + // Set Gemini config for structured output + generationConfig.responseMimeType = "application/json"; + generationConfig.responseSchema = convertedSchema; + + this.logger.info( + `[Adapter] Converted OpenAI response_format to Gemini responseSchema: ${jsonSchema.name || "unnamed"}` + ); + + // Log warning if tools are also present (may cause conflicts) + if (googleRequest.tools && googleRequest.tools.length > 0) { + this.logger.warn( + "[Adapter] ⚠️ Both response_format and tools are present. This may cause unexpected behavior." + ); + } + } catch (error) { + this.logger.error( + `[Adapter] Failed to convert response_format schema: ${error.message}`, + error + ); + } + } + } else if (responseFormat.type === "json_object") { + // Simple JSON mode without schema validation + generationConfig.responseMimeType = "application/json"; + this.logger.info("[Adapter] Enabled JSON mode (no schema validation)"); + } else if (responseFormat.type === "text") { + // Explicit text mode (default behavior, no action needed) + this.logger.debug("[Adapter] Response format set to text (default)"); + } else { + this.logger.warn(`[Adapter] Unsupported response_format type: ${responseFormat.type}. Ignoring.`); + } + } + // Force web search and URL context if (this.serverSystem.forceWebSearch || this.serverSystem.forceUrlContext) { if (!googleRequest.tools) {