Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CONTRIBUTORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
32 changes: 32 additions & 0 deletions kiro/converters_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions kiro/json_response_format.py
Original file line number Diff line number Diff line change
@@ -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
14 changes: 13 additions & 1 deletion kiro/routes_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")

Expand Down