Skip to content

feat: strip markdown code blocks from JSON responses#288

Closed
rothnic wants to merge 3 commits intodecolua:masterfrom
rothnic:feat/markdown-json-stripping
Closed

feat: strip markdown code blocks from JSON responses#288
rothnic wants to merge 3 commits intodecolua:masterfrom
rothnic:feat/markdown-json-stripping

Conversation

@rothnic
Copy link
Contributor

@rothnic rothnic commented Mar 12, 2026

Problem

AI SDK generateObject fails with "Invalid JSON response" when using kimi models through 9router.

Root Cause Investigation

Tested: Direct API calls to kimi with response_format: {type: "json_object"}

Findings:

  • kimi models natively support JSON mode via Moonshot API
  • However, kimi wraps JSON responses in markdown code blocks (```json...```)
  • Example response: ```json\n{"key": "value"}\n```
  • AI SDK expects raw JSON, not markdown-wrapped JSON
  • This causes AI_APICallError: Invalid JSON response in AI SDK 5.x

Why kimi outputs markdown:

  • Despite explicit system prompts saying "Respond ONLY with raw JSON"
  • kimi still wraps in markdown, possibly training data influence
  • This happens in BOTH json_object and json_schema modes

Related Work

This PR builds on previous response_format support:

Those PRs added the foundation for translating response_format to Claude-compatible system prompts. This PR adds the final layer: stripping markdown that some models (kimi, etc.) add despite explicit instructions.

Solution

Strip markdown code block markers from JSON responses only when response_format is set.

Implementation Details

Conditional Application:

// Only process if response_format is specified
if (body?.response_format) {
  const isStructuredOutput = 
    responseFormat.type === "json_schema" || 
    responseFormat.type === "json_object";
  
  if (isStructuredOutput) {
    // Strip markdown markers
    text = text.replace(/^\s*\`\`\`\s*json\s*\n?/i, "")
               .replace(/\n?\s*\`\`\`\s*$/i, "");
  }
}

Safety Measures

  1. Mode-gated: Only applies when response_format.type is json_schema or json_object
  2. Regex boundaries: Only matches at start/end of content with word boundaries
  3. Case-insensitive: Handles json, JSON, Json variations
  4. Optional whitespace: Accounts for formatting variations
  5. Preserves inline: Does NOT strip backticks from inline code or explanations

What This Protects

Strips: Code block wrappers in structured output mode

{"key": "value"}  // Result after stripping

Preserves: Inline backticks in normal chat

  • "Use the config.json file" stays intact
  • "The auth header should contain..." stays intact

Changes

Files Modified

  1. open-sse/translator/request/openai-to-claude.js

    • Translates OpenAI response_format to Claude-compatible system prompts
    • Explicitly instructs: "Return ONLY raw JSON without markdown"
  2. open-sse/translator/response/claude-to-openai.js

    • Strips markdown from streaming text_delta chunks
    • Applied only when response_format present in request
  3. open-sse/handlers/chatCore/nonStreamingHandler.js

    • Strips markdown from non-streaming Claude format responses
    • Applied only to final assembled content
  4. open-sse/utils/stream.js & jsonExtractor.js (new)

    • Centralized utility for consistent markdown extraction
    • Validates JSON structure before/after stripping

Testing

Verified Scenarios

  • response_format: {type: "json_object"} → markdown stripped
  • response_format: {type: "json_schema", json_schema: {...}} → markdown stripped
  • ✅ Normal chat (no response_format) → backticks preserved
  • ✅ Inline code in explanations → backticks preserved
  • ✅ Multiple models: kimi/kimi-k2.5-thinking, gh/gpt-4o

Test Command

curl -X POST http://localhost:20128/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d "{\"model\": \"kimi/kimi-k2.5-thinking\", \"messages\": [{\"role\": \"user\", \"content\": \"Return JSON: {\\\"test\\\": \\\"value\\\"}\"}], \"response_format\": {\"type\": \"json_object\"}}"

Before fix: Returns \``json\n{"test": "value"}\n```**After fix:** Returns{"test": "value"}` ✓

Global Application

These changes are conditionally applied based on request context:

  • Only active when client explicitly requests structured JSON output
  • Safe for all models (idempotent operation)
  • No performance impact on normal chat requests

Related

…ders

Providers like Antigravity maintain separate quota buckets per model family
(e.g. Claude vs Gemini). A 429 on claude-opus previously locked the entire
account, preventing gemini-pro requests even though its quota was full.

This adds in-memory per-model locking so that only the specific model is
skipped during account selection while other models remain accessible.

Changes:
- Add model-aware lock tracking in auth.js (Map<connectionId:model, expiry>)
- Pass model context from chat handler to auth service
- Multi-bucket behavior gated to known providers (MULTI_BUCKET_PROVIDERS set)
- No database schema changes — locks are in-memory and clear on restart

Closes decolua#110
When models (like kimi) return JSON wrapped in markdown code blocks
(\`\`\`json...\`\`\`), strip the markers before sending to client.

Changes:
- Add markdown stripping to streaming text_delta responses
- Add markdown stripping to non-streaming Claude format responses
- Translate response_format to system prompts for JSON schema/object modes
- Add jsonExtractor utility for consistent markdown removal

This fixes AI_APICallError: Invalid JSON response when using
AI SDK generateObject with kimi models through 9router.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants