diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 00c02dce..0d43f6f5 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -14,3 +14,4 @@ Thank you to all the contributors who have helped improve this project! - [@PAzter1101](https://github.com/PAzter1101) — Docker containerization with CI/CD (#55) - [@Ry-DS](https://github.com/Ry-DS) — Images in tool results support for Anthropic MCP servers (#57) - [@saaj](https://github.com/saaj) — Regional endpoint fix for eu-central-1 and other non-us-east-1 regions (#58) +- [@lukasBlckIT](https://github.com/lukasBlckIT) — response_format json_object support (#123) diff --git a/kiro/converters_openai.py b/kiro/converters_openai.py index aad3b83c..35b1ccbf 100644 --- a/kiro/converters_openai.py +++ b/kiro/converters_openai.py @@ -296,6 +296,33 @@ def convert_openai_tools_to_unified(tools: Optional[List[Tool]]) -> Optional[Lis # Main Entry Point # ================================================================================================== +def _wants_json_response(request_data: ChatCompletionRequest) -> bool: + """ + Check if the request has response_format with type 'json_object'. + + Since ChatCompletionRequest uses extra='allow', response_format + is accepted but not explicitly modeled. We check it dynamically. + """ + rf = getattr(request_data, "response_format", None) + if rf is None: + return False + if isinstance(rf, dict): + return rf.get("type") == "json_object" + # Pydantic model or other object + return getattr(rf, "type", None) == "json_object" + + +JSON_SYSTEM_PROMPT_ADDITION = ( + "\n\n---\n" + "# Response Format: JSON\n\n" + "The caller has requested JSON output (response_format: json_object).\n" + "You MUST respond with raw, valid JSON only.\n" + "Do NOT wrap the response in markdown code blocks (```json ... ``` or ``` ... ```).\n" + "Do NOT add any text before or after the JSON.\n" + "The response must be parseable by JSON.parse() directly." +) + + def build_kiro_payload( request_data: ChatCompletionRequest, conversation_id: str, @@ -324,6 +351,11 @@ def build_kiro_payload( # Convert tools to unified format unified_tools = convert_openai_tools_to_unified(request_data.tools) + # If response_format is json_object, inject JSON instruction into system prompt + if _wants_json_response(request_data): + system_prompt = (system_prompt + JSON_SYSTEM_PROMPT_ADDITION) if system_prompt else JSON_SYSTEM_PROMPT_ADDITION.strip() + logger.info("response_format=json_object detected, injecting JSON system prompt") + # Get model ID for Kiro API (normalizes + resolves hidden models) # Pass-through principle: we normalize and send to Kiro, Kiro decides if valid model_id = get_model_id_for_kiro(request_data.model, HIDDEN_MODELS) diff --git a/kiro/json_response_format.py b/kiro/json_response_format.py new file mode 100644 index 00000000..7bc23db7 --- /dev/null +++ b/kiro/json_response_format.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +""" +Utilities for handling response_format: json_object. + +Strips markdown code block wrappers from responses when the caller +requested raw JSON output via response_format. +""" + +import re + +# Matches ```json\n...\n``` or ```\n...\n``` wrapping the entire response +_MD_JSON_BLOCK_RE = re.compile( + r'^\s*```(?:json)?\s*\n(.*?)\n\s*```\s*$', + re.DOTALL +) + + +def strip_markdown_json_wrapper(content: str) -> str: + """ + Strip markdown code block wrapper from JSON content. + + If the content is wrapped in ```json ... ``` or ``` ... ```, + extract the inner content. Only strips if the entire response + is a single code block. + + Args: + content: Response content that may be markdown-wrapped + + Returns: + Unwrapped content, or original content if no wrapper found + """ + if not content: + return content + + m = _MD_JSON_BLOCK_RE.match(content) + if m: + return m.group(1).strip() + + return content diff --git a/kiro/routes_openai.py b/kiro/routes_openai.py index 301ae9b5..3367f2c0 100644 --- a/kiro/routes_openai.py +++ b/kiro/routes_openai.py @@ -46,8 +46,9 @@ from kiro.auth import KiroAuthManager, AuthType from kiro.cache import ModelInfoCache from kiro.model_resolver import ModelResolver -from kiro.converters_openai import build_kiro_payload +from kiro.converters_openai import build_kiro_payload, _wants_json_response from kiro.streaming_openai import stream_kiro_to_openai, collect_stream_response, stream_with_first_token_retry +from kiro.json_response_format import strip_markdown_json_wrapper from kiro.http_client import KiroHttpClient from kiro.utils import generate_conversation_id @@ -393,6 +394,17 @@ async def stream_wrapper(): await http_client.close() + # Strip markdown JSON wrapper if response_format is json_object + if _wants_json_response(request_data): + try: + content = openai_response["choices"][0]["message"]["content"] + stripped = strip_markdown_json_wrapper(content) + if stripped != content: + openai_response["choices"][0]["message"]["content"] = stripped + logger.info("Stripped markdown JSON wrapper from non-streaming response") + except (KeyError, IndexError): + pass + # Log access log for non-streaming success logger.info(f"HTTP 200 - POST /v1/chat/completions (non-streaming) - completed")