Skip to content
Draft
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 .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ source/*
.cursor/*
docs/*
!docs/ARCHITECTURE.md
!docs/investigations/
test/*
bin/*
open-sse/test/*
Expand Down
84 changes: 84 additions & 0 deletions docs/investigations/kimi-json-mode-behavior.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Investigation: kimi JSON Mode Behavior Through 9router

## Summary

We discovered that kimi models behave differently depending on the variant when accessed through 9router's Claude compatibility layer.

## Models Tested

### Non-GH Models Available Through 9router:
- **kimi/kimi-k2.5-thinking** - ADDS markdown code blocks ❌
- **kimi/kimi-k2.5** - Clean JSON ✅
- **kimi/kimi-latest** - Clean JSON ✅
- **free** (OpenCode) - Clean JSON ✅
- **balance** (OpenCode) - Clean JSON ✅
- **sota** (OpenCode) - Clean JSON ✅

## Key Finding

**kimi/kimi-k2.5-thinking is the only model that wraps JSON in markdown code blocks** despite:
- Setting `response_format: {type: "json_object"}`
- Explicit system prompts saying "return ONLY raw JSON"

This appears to be a behavior of the "thinking" variant specifically when accessed through the Claude compatibility layer.

## Root Cause

9router translates OpenAI API requests to Claude-compatible format. The kimi models are accessed via third-party providers (likely OpenRouter or similar) that expose them through a Claude-compatible interface.

The "thinking" variant seems to:
1. Process the request through its reasoning/thinking mechanism
2. Output the thinking process wrapped in markdown
3. Include the JSON response inside markdown code blocks

## Recommendation

**Use kimi/kimi-k2.5 instead of kimi/kimi-k2.5-thinking** for JSON mode operations.

The non-thinking variant provides:
- Clean JSON output without markdown wrapping
- Lower cost (no thinking tokens)
- Faster response times
- Better compatibility with structured output

## Why Keep Markdown Stripping (PR #288)

Even though we found that kimi-k2.5 works without markdown, we should keep the markdown stripping as a **defensive backstop** because:

1. **Model behavior can change** - Future updates to kimi might add markdown
2. **Configuration issues** - Provider misconfigurations could cause markdown output
3. **Other models** - Future models added might have similar issues
4. **Streaming edge cases** - Some streaming scenarios might still produce markdown

The stripping is:
- **Safe** - Idempotent operation (running twice doesn't break anything)
- **Conditional** - Only applies when `response_format` is set
- **Conservative** - Only strips actual markdown code block markers

## Alternative: Native Moonshot API

We attempted to test kimi directly through Moonshot's native API but encountered authentication issues with the provided API key.

**Pros of Native API:**
- Direct access to kimi's native capabilities
- Potentially better JSON mode adherence
- No translation layer overhead

**Cons of Native API:**
- Requires separate provider implementation in 9router
- Different API format (not OpenAI-compatible)
- Additional maintenance burden

**Recommendation:** Stick with 9router's Claude-compatible layer but use `kimi/kimi-k2.5` (non-thinking) for JSON operations.

## Action Items

1. ✅ Document this behavior in 9router PR #288
2. ✅ Update job-tracker-opencode config to use `kimi/kimi-k2.5` instead of `kimi/kimi-k2.5-thinking`
3. ✅ Keep markdown stripping as defensive measure
4. ✅ Test other models periodically as they're added

## Files Changed

- `.env` - Change model from `kimi/kimi-k2.5-thinking` to `kimi/kimi-k2.5`
- PR #288 remains valuable as defensive backstop
13 changes: 11 additions & 2 deletions open-sse/handlers/chatCore.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { refreshWithRetry } from "../services/tokenRefresh.js";
import { createRequestLogger } from "../utils/requestLogger.js";
import { getModelTargetFormat, PROVIDER_ID_TO_ALIAS } from "../config/providerModels.js";
import { createErrorResult, parseUpstreamError, formatProviderError } from "../utils/error.js";
import { HTTP_STATUS } from "../config/runtimeConfig.js";
import { HTTP_STATUS } from "../config/constants.js";
import { handleBypassRequest } from "../utils/bypassHandler.js";
import { trackPendingRequest, appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
import { getExecutor } from "../executors/index.js";
Expand Down Expand Up @@ -39,7 +39,16 @@ export async function handleChatCore({ body, modelInfo, credentials, log, onCred

const clientRequestedStreaming = body.stream === true || sourceFormat === FORMATS.ANTIGRAVITY || sourceFormat === FORMATS.GEMINI || sourceFormat === FORMATS.GEMINI_CLI;
const providerRequiresStreaming = provider === "openai" || provider === "codex";
const stream = providerRequiresStreaming ? true : (body.stream !== false);
let stream = providerRequiresStreaming ? true : (body.stream !== false);

// Check client Accept header preference for non-streaming requests
// This fixes AI SDK compatibility where clients send Accept: application/json
const acceptHeader = clientRawRequest?.headers?.accept || "";
const clientPrefersJson = acceptHeader.includes("application/json");
const clientPrefersSSE = acceptHeader.includes("text/event-stream");
if (clientPrefersJson && !clientPrefersSSE && body.stream !== true) {
stream = false;
}

const reqLogger = await createRequestLogger(sourceFormat, targetFormat, model);
if (clientRawRequest) reqLogger.logClientRawRequest(clientRawRequest.endpoint, clientRawRequest.body, clientRawRequest.headers);
Expand Down
15 changes: 6 additions & 9 deletions open-sse/handlers/chatCore/nonStreamingHandler.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { FORMATS } from "../../translator/formats.js";
import { needsTranslation } from "../../translator/index.js";
import { ollamaBodyToOpenAI } from "../../translator/response/ollama-to-openai.js";
import { addBufferToUsage, filterUsageForFormat } from "../../utils/usageTracking.js";
import { createErrorResult } from "../../utils/error.js";
import { HTTP_STATUS } from "../../config/runtimeConfig.js";
import { HTTP_STATUS } from "../../config/constants.js";
import { parseSSEToOpenAIResponse } from "./sseToJsonHandler.js";
import { buildRequestDetail, extractRequestConfig, extractUsageFromResponse, saveUsageStats } from "./requestDetail.js";
import { appendRequestLog, saveRequestDetail } from "@/lib/usageDb.js";
Expand Down Expand Up @@ -77,8 +76,11 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
const toolCalls = [];

for (const block of responseBody.content) {
if (block.type === "text") textContent += block.text;
else if (block.type === "thinking") thinkingContent += block.thinking || "";
if (block.type === "text") {
let text = block.text;
text = text.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, "");
textContent += text;
}
else if (block.type === "tool_use") {
toolCalls.push({ id: block.id, type: "function", function: { name: block.name, arguments: JSON.stringify(block.input || {}) } });
}
Expand Down Expand Up @@ -112,11 +114,6 @@ export function translateNonStreamingResponse(responseBody, targetFormat, source
return result;
}

// Ollama
if (targetFormat === FORMATS.OLLAMA) {
return ollamaBodyToOpenAI(responseBody);
}

return responseBody;
}

Expand Down
9 changes: 4 additions & 5 deletions open-sse/translator/request/openai-to-claude.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { register } from "../index.js";
import { FORMATS } from "../formats.js";
import { CLAUDE_SYSTEM_PROMPT } from "../../config/appConstants.js";
import { CLAUDE_SYSTEM_PROMPT } from "../../config/constants.js";
import { adjustMaxTokens } from "../helpers/maxTokensHelper.js";

// Empty prefix matches real Claude Code behavior (no tool name prefix).
Expand Down Expand Up @@ -106,10 +106,9 @@ export function openaiToClaudeRequest(model, body, stream) {
if (responseFormat.type === "json_schema" && responseFormat.json_schema?.schema) {
const schemaJson = JSON.stringify(responseFormat.json_schema.schema, null, 2);
systemParts.push(`You must respond with valid JSON that strictly follows this JSON schema:
\`\`\`json
${schemaJson}
\`\`\`
Respond ONLY with the JSON object, no other text.`);
Schema: ${schemaJson}

IMPORTANT: Return ONLY the raw JSON object without markdown formatting, code blocks, or backticks.`);
} else if (responseFormat.type === "json_object") {
systemParts.push("You must respond with valid JSON. Respond ONLY with a JSON object, no other text.");
}
Expand Down
5 changes: 4 additions & 1 deletion open-sse/translator/response/claude-to-openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ export function claudeToOpenAIResponse(chunk, state) {
if (chunk.index === state.serverToolBlockIndex) break;
const delta = chunk.delta;
if (delta?.type === "text_delta" && delta.text) {
results.push(createChunk(state, { content: delta.text }));
let text = delta.text;
// Strip markdown code block markers
text = text.replace(/^\s*```\s*json\s*\n?/i, "").replace(/\n?\s*```\s*$/i, "");
results.push(createChunk(state, { content: text }));
} else if (delta?.type === "thinking_delta" && delta.thinking) {
results.push(createChunk(state, { reasoning_content: delta.thinking }));
} else if (delta?.type === "input_json_delta" && delta.partial_json) {
Expand Down
34 changes: 34 additions & 0 deletions open-sse/utils/jsonExtractor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/**
* Extract JSON from content that may be wrapped in markdown code blocks
* Only processes content when responseFormat is specified
* @param {string} content - Raw response content
* @param {object} responseFormat - The response_format from the request body
* @returns {string} - Extracted JSON or original content
*/
export function extractJSON(content, responseFormat) {
// Only process if response_format is specified
if (!responseFormat || !content || typeof content !== 'string') {
return content;
}

// Check if it's a structured output request
const isStructuredOutput = responseFormat.type === 'json_schema' ||
responseFormat.type === 'json_object';

if (!isStructuredOutput) {
return content;
}

// Try to find JSON between markdown code blocks
const codeBlockMatch = content.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/i);
if (codeBlockMatch && codeBlockMatch[1]) {
const extracted = codeBlockMatch[1].trim();
// Validate it looks like JSON
if (extracted.startsWith('{') || extracted.startsWith('[')) {
return extracted;
}
}

// Return original if no markdown found or doesn't look like JSON
return content;
}
23 changes: 15 additions & 8 deletions open-sse/utils/stream.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { extractJSON } from './jsonExtractor.js';
import { translateResponse, initState } from "../translator/index.js";
import { FORMATS } from "../translator/formats.js";
import { trackPendingRequest, appendRequestLog } from "@/lib/usageDb.js";
Expand Down Expand Up @@ -159,7 +160,7 @@ export function createSSEStream(options = {}) {
// Translate mode
if (!trimmed) continue;

const parsed = parseSSELine(trimmed, targetFormat);
const parsed = parseSSELine(trimmed);
if (!parsed) continue;

if (parsed && parsed.done) {
Expand Down Expand Up @@ -278,13 +279,16 @@ export function createSSEStream(options = {}) {
// Some clients (e.g. OpenClaw) expect the OpenAI-style sentinel:
// data: [DONE]\n\n
// Without it they can hang until timeout and trigger failover.
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(sharedEncoder.encode(doneOutput));
// Only emit data: [DONE] for streaming requests
if (body?.stream === true) {
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(sharedEncoder.encode(doneOutput));
}

if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
content: extractJSON(accumulatedContent, body?.response_format),
thinking: accumulatedThinking
}, usage, ttftAt);
}
Expand Down Expand Up @@ -330,9 +334,12 @@ export function createSSEStream(options = {}) {
}
}

const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
// Only emit data: [DONE] for streaming requests
if (body?.stream === true) {
const doneOutput = "data: [DONE]\n\n";
reqLogger?.appendConvertedChunk?.(doneOutput);
controller.enqueue(sharedEncoder.encode(doneOutput));
}

if (!hasValidUsage(state?.usage) && totalContentLength > 0) {
state.usage = estimateUsage(body, totalContentLength, sourceFormat);
Expand All @@ -346,7 +353,7 @@ export function createSSEStream(options = {}) {

if (onStreamComplete) {
onStreamComplete({
content: accumulatedContent,
content: extractJSON(accumulatedContent, body?.response_format),
thinking: accumulatedThinking
}, state?.usage, ttftAt);
}
Expand Down