From d9880f440042fd982cf2dbb748b654b6ef070476 Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Wed, 13 May 2026 14:26:00 -0300 Subject: [PATCH 1/8] fix(shim): consume requireReasoningContentOnAssistantMessages and preserve reasoning through local proxies MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What changed: `requireReasoningContentOnAssistantMessages` was defined in descriptors.ts and set by five vendor configs (deepseek, moonshot, mimo, zai, kimi-code) but never consumed in the OpenAI shim's message conversion pipeline. This meant any provider that required reasoning_content on assistant messages would get a 400 error from the API ("reasoning_content must be passed back to the API") whenever the flag was the only source of truth. Additionally, the `treatAsLocal` code path in runtimeMetadata.ts bypassed model-name-based config inference entirely, so local proxies (e.g. key routers like grouter) forwarding to remote reasoning models never received reasoning-related config even when the model name clearly indicated a reasoning provider. Why it changed: - The flag was a dead config — set but never read — causing 400 errors for all five vendors that rely on it - Local proxies that forward to reasoning providers need the same config as direct connections; the model name (e.g. "mimo-v2.5-pro") is sufficient signal regardless of whether the base URL is localhost or remote - The empty-string reasoning_content fallback was gated on toolUses.length > 0, so plain-text assistant messages without tool calls were missing the field - The coalescing pass silently dropped reasoning_content when merging consecutive assistant messages - Synthetic assistant messages injected during tool→user gaps and non-array content assistant messages never received reasoning_content User/developer impact: - Fixes 400 errors for DeepSeek, Moonshot/Kimi, MiMo, ZAI, and any future vendor that sets requireReasoningContentOnAssistantMessages: true - Enables reasoning models to work through local proxies/key routers without manual config - No behavior change for providers that do not set the flag (all defaults remain false/undefined) Checks run: - bun run build — clean - bun run smoke — pass - bun test src/services/api/openaiShim — 104 pass, 0 fail --- src/integrations/runtimeMetadata.ts | 19 +++++++++++- src/services/api/openaiShim.ts | 45 +++++++++++++++++++++++++---- 2 files changed, 57 insertions(+), 7 deletions(-) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index f1bd4a02f6..8c2f9033b1 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -215,12 +215,29 @@ export function resolveOpenAIShimRuntimeContext(options?: { descriptor && routeId ? getCatalogEntryForModel(routeId, options?.model) : null + const remoteModelInferredConfig = inferRemoteModelOpenAIShimConfig(options?.model) const inferredConfig = options?.treatAsLocal === true ? { maxTokensField: 'max_tokens' as const, + // Local proxies (e.g. key routers like grouter) may forward to remote + // reasoning models that require reasoning_content. Preserve the + // model-inferred config for reasoning fields so local routing doesn't + // strip thinking continuity. + ...(remoteModelInferredConfig?.preserveReasoningContent !== undefined + ? { preserveReasoningContent: remoteModelInferredConfig.preserveReasoningContent } + : {}), + ...(remoteModelInferredConfig?.requireReasoningContentOnAssistantMessages !== undefined + ? { requireReasoningContentOnAssistantMessages: remoteModelInferredConfig.requireReasoningContentOnAssistantMessages } + : {}), + ...(remoteModelInferredConfig?.reasoningContentFallback !== undefined + ? { reasoningContentFallback: remoteModelInferredConfig.reasoningContentFallback } + : {}), + ...(remoteModelInferredConfig?.thinkingRequestFormat !== undefined + ? { thinkingRequestFormat: remoteModelInferredConfig.thinkingRequestFormat } + : {}), } - : inferRemoteModelOpenAIShimConfig(options?.model) + : remoteModelInferredConfig return { routeId, diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index b6f054961a..eb5535f8ac 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -487,11 +487,13 @@ function convertMessages( system: unknown, options?: { preserveReasoningContent?: boolean + requireReasoningContentOnAssistantMessages?: boolean reasoningContentFallback?: '' | 'omit' preserveGeminiThoughtSignature?: boolean }, ): OpenAIMessage[] { const preserveReasoningContent = options?.preserveReasoningContent === true + const requireReasoningContent = options?.requireReasoningContentOnAssistantMessages === true const reasoningContentFallback = options?.reasoningContentFallback const preserveGeminiThoughtSignature = options?.preserveGeminiThoughtSignature === true const result: OpenAIMessage[] = [] @@ -608,12 +610,14 @@ function convertMessages( const thinkingText = (thinkingBlock as { thinking?: string } | undefined)?.thinking if (typeof thinkingText === 'string' && thinkingText.trim().length > 0) { assistantMsg.reasoning_content = thinkingText - } else if ( - toolUses.length > 0 && - reasoningContentFallback === '' - ) { + } else if (reasoningContentFallback === '') { assistantMsg.reasoning_content = '' } + } else if (requireReasoningContent) { + // Provider requires reasoning_content on every assistant message but + // preserveReasoningContent is not set (no thinking block to echo). + // Attach empty string to satisfy API validation. + assistantMsg.reasoning_content = '' } if (toolUses.length > 0) { @@ -699,6 +703,14 @@ function convertMessages( })(), } + // For providers that require reasoning_content on every assistant message, + // attach an empty string when no thinking block is present. + if (preserveReasoningContent && reasoningContentFallback === '') { + assistantMsg.reasoning_content = '' + } else if (requireReasoningContent) { + assistantMsg.reasoning_content = '' + } + if (assistantMsg.content) { result.push(assistantMsg) } @@ -720,10 +732,18 @@ function convertMessages( // assistant response to satisfy the strict role sequence: // ... -> assistant (calls) -> tool (results) -> assistant (semantic) -> user (next) if (prev && prev.role === 'tool' && msg.role === 'user') { - coalesced.push({ + const syntheticMsg: OpenAIMessage = { role: 'assistant', content: '[Tool execution interrupted by user]', - }) + } + // Providers that require reasoning_content on every assistant message + // need it on this synthetic message too, or the API returns 400. + if (preserveReasoningContent && reasoningContentFallback === '') { + syntheticMsg.reasoning_content = '' + } else if (requireReasoningContent) { + syntheticMsg.reasoning_content = '' + } + coalesced.push(syntheticMsg) } const lastAfterPossibleInjection = coalesced[coalesced.length - 1] @@ -766,6 +786,18 @@ function convertMessages( ...msg.tool_calls, ] } + + // Preserve reasoning_content when coalescing assistant messages. + // Prefer the incoming message's reasoning_content if present, otherwise + // keep the existing one. Both cannot be preserved simultaneously since + // the field is singular per message. + if ( + msg.role === 'assistant' && + msg.reasoning_content !== undefined && + lastAfterPossibleInjection.role === 'assistant' + ) { + lastAfterPossibleInjection.reasoning_content = msg.reasoning_content + } } else { coalesced.push(msg) } @@ -1782,6 +1814,7 @@ class OpenAIShimMessages { const shimConfig = runtimeShimContext.openaiShimConfig const openaiMessages = convertMessages(compressedMessages, params.system, { preserveReasoningContent: shimConfig.preserveReasoningContent, + requireReasoningContentOnAssistantMessages: shimConfig.requireReasoningContentOnAssistantMessages, reasoningContentFallback: shimConfig.reasoningContentFallback, preserveGeminiThoughtSignature: shouldPreserveGeminiThoughtSignature( request.resolvedModel, From d10adc5799778fa94acad77fb0261a800ac32840 Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Wed, 13 May 2026 16:24:27 -0300 Subject: [PATCH 2/8] fix: preserve non-empty reasoning_content during coalescing When coalescing consecutive assistant messages, an empty reasoning_content fallback ("") would overwrite a non-empty thinking text from a previous message. Now we only overwrite when the incoming value is non-empty or the existing value is already empty/undefined. --- src/services/api/openaiShim.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/services/api/openaiShim.ts b/src/services/api/openaiShim.ts index eb5535f8ac..3ec157246d 100644 --- a/src/services/api/openaiShim.ts +++ b/src/services/api/openaiShim.ts @@ -788,15 +788,19 @@ function convertMessages( } // Preserve reasoning_content when coalescing assistant messages. - // Prefer the incoming message's reasoning_content if present, otherwise - // keep the existing one. Both cannot be preserved simultaneously since - // the field is singular per message. + // Never overwrite a non-empty reasoning_content with an empty one, + // since empty values are fallback placeholders while non-empty ones + // carry actual thinking text that providers require. if ( msg.role === 'assistant' && msg.reasoning_content !== undefined && lastAfterPossibleInjection.role === 'assistant' ) { - lastAfterPossibleInjection.reasoning_content = msg.reasoning_content + const existing = lastAfterPossibleInjection.reasoning_content + const incoming = msg.reasoning_content + if (typeof existing !== 'string' || existing.length === 0 || incoming.length > 0) { + lastAfterPossibleInjection.reasoning_content = incoming + } } } else { coalesced.push(msg) From abb653e9112a4860df3368620be83db7fb382131 Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Thu, 14 May 2026 07:24:34 -0300 Subject: [PATCH 3/8] fix: add model-name inference for MiMo and GLM/Z.AI in local proxy inferRemoteModelOpenAIShimConfig() only recognized deepseek, kimi, and moonshot. When using a local proxy (e.g. GRouter) with mimo-v2.5-pro or GLM-5.1, the function returned undefined, so these models got no reasoning config even with the treatAsLocal merge fix. Now mimo and glm patterns are recognized and return the same reasoning config their vendor files define. --- src/integrations/runtimeMetadata.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 8c2f9033b1..2e896d3b16 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -163,6 +163,28 @@ function inferRemoteModelOpenAIShimConfig( } } + if (normalizedModel.includes('mimo')) { + return { + preserveReasoningContent: true, + requireReasoningContentOnAssistantMessages: true, + reasoningContentFallback: '', + thinkingRequestFormat: 'deepseek-compatible', + maxTokensField: 'max_tokens', + removeBodyFields: ['store'], + } + } + + if (normalizedModel.includes('glm')) { + return { + preserveReasoningContent: true, + requireReasoningContentOnAssistantMessages: true, + reasoningContentFallback: '', + thinkingRequestFormat: 'deepseek-compatible', + maxTokensField: 'max_tokens', + removeBodyFields: ['store'], + } + } + return undefined } From 03303ea0512be39bf7527c476ed30b03fd1cdd65 Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Fri, 15 May 2026 11:15:22 -0300 Subject: [PATCH 4/8] fix: scope mimo/glm inference to reasoning-only fields InferRemoteModelOpenAIShimConfig for mimo and glm returned maxTokensField and removeBodyFields, which overrode the Xiaomi MiMo vendor descriptor's max_completion_tokens when the direct route was used. Model-name inference should only return reasoning- related fields (preserveReasoningContent, requireReasoningContent, reasoningContentFallback, thinkingRequestFormat) so the descriptor remains the source of truth for transport fields like maxTokensField. --- src/integrations/runtimeMetadata.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 2e896d3b16..becb3557ff 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -169,8 +169,6 @@ function inferRemoteModelOpenAIShimConfig( requireReasoningContentOnAssistantMessages: true, reasoningContentFallback: '', thinkingRequestFormat: 'deepseek-compatible', - maxTokensField: 'max_tokens', - removeBodyFields: ['store'], } } @@ -180,8 +178,6 @@ function inferRemoteModelOpenAIShimConfig( requireReasoningContentOnAssistantMessages: true, reasoningContentFallback: '', thinkingRequestFormat: 'deepseek-compatible', - maxTokensField: 'max_tokens', - removeBodyFields: ['store'], } } From 1aeace603da234e5794c1bca0b3fda3c7390c77f Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Sat, 16 May 2026 08:52:02 -0300 Subject: [PATCH 5/8] fix: preserve descriptor maxTokensField over model-inferred value mergeOpenAIShimConfig now checks for a descriptor-level maxTokensField before applying the model-inferred value. This prevents direct providers (e.g. Xiaomi with max_completion_tokens) from being overridden by inference results that default to max_tokens. Gateway routes without a descriptor still receive the inferred value. --- src/integrations/runtimeMetadata.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index becb3557ff..f38821fb50 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -92,10 +92,19 @@ function mergeOpenAIShimConfig( entryConfig: Partial | undefined, inferredConfig: Partial | undefined, ): OpenAIShimTransportConfig { + const descriptorMaxTokensField = + entryConfig?.maxTokensField ?? baseConfig?.maxTokensField + return { ...baseConfig, ...entryConfig, ...inferredConfig, + // Preserve descriptor-level maxTokensField over model-inferred value. + // Direct providers (e.g. Xiaomi) declare their own token field in the + // descriptor; model-name inference should not override it. + ...(descriptorMaxTokensField !== undefined + ? { maxTokensField: descriptorMaxTokensField } + : {}), removeBodyFields: mergeRemoveBodyFields( baseConfig?.removeBodyFields, entryConfig?.removeBodyFields, From c0e17229358815a479d131f4fe7e42757d667e97 Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Sat, 16 May 2026 08:57:28 -0300 Subject: [PATCH 6/8] fix: propagate removeBodyFields in treatAsLocal path The treatAsLocal code path was dropping removeBodyFields from the model-inference config. For DeepSeek/Kimi models, inference returns removeBodyFields: ['store'] which was silently lost for local proxies. Currently mitigated by the isLocal store-stripping check, but this ensures future removeBodyFields entries are also preserved. --- src/integrations/runtimeMetadata.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index f38821fb50..51dd196ba3 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -263,6 +263,9 @@ export function resolveOpenAIShimRuntimeContext(options?: { ...(remoteModelInferredConfig?.thinkingRequestFormat !== undefined ? { thinkingRequestFormat: remoteModelInferredConfig.thinkingRequestFormat } : {}), + ...(remoteModelInferredConfig?.removeBodyFields !== undefined + ? { removeBodyFields: remoteModelInferredConfig.removeBodyFields } + : {}), } : remoteModelInferredConfig From 023c71c64e7af777b63f354048a9444f8976ca19 Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Sat, 16 May 2026 18:32:01 -0300 Subject: [PATCH 7/8] fix: set maxTokensField for MiMo and GLM model inference MiMo's API expects `max_completion_tokens` (matching its vendor descriptor), but the model-name inference was not setting maxTokensField. When going through a localhost proxy (treatAsLocal path), the default `max_tokens` was applied instead, causing the API to ignore the token limit and terminate responses prematurely. Also adds missing removeBodyFields: ['store'] for both MiMo and GLM to match the pattern used by DeepSeek and Kimi inference configs. --- src/integrations/runtimeMetadata.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 51dd196ba3..8e6a0fe106 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -178,6 +178,8 @@ function inferRemoteModelOpenAIShimConfig( requireReasoningContentOnAssistantMessages: true, reasoningContentFallback: '', thinkingRequestFormat: 'deepseek-compatible', + maxTokensField: 'max_completion_tokens', + removeBodyFields: ['store'], } } @@ -187,6 +189,8 @@ function inferRemoteModelOpenAIShimConfig( requireReasoningContentOnAssistantMessages: true, reasoningContentFallback: '', thinkingRequestFormat: 'deepseek-compatible', + maxTokensField: 'max_tokens', + removeBodyFields: ['store'], } } From 103130e1ea25c6089d7dd1223aba4c2466213290 Mon Sep 17 00:00:00 2001 From: JoaoPedroCampanari Date: Sat, 16 May 2026 19:21:16 -0300 Subject: [PATCH 8/8] fix: propagate maxTokensField from model inference in treatAsLocal path The treatAsLocal code path hardcoded maxTokensField to 'max_tokens', which broke providers like MiMo that require 'max_completion_tokens' even when accessed through a local proxy (e.g. GRouter). Now checks if remoteModelInferredConfig has a maxTokensField before falling back to 'max_tokens', matching the pattern already used for reasoning fields. --- src/integrations/runtimeMetadata.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/integrations/runtimeMetadata.ts b/src/integrations/runtimeMetadata.ts index 8e6a0fe106..9b46c1edaf 100644 --- a/src/integrations/runtimeMetadata.ts +++ b/src/integrations/runtimeMetadata.ts @@ -250,7 +250,13 @@ export function resolveOpenAIShimRuntimeContext(options?: { const inferredConfig = options?.treatAsLocal === true ? { - maxTokensField: 'max_tokens' as const, + // Don't hardcode maxTokensField here — let the descriptor (via + // mergeOpenAIShimConfig) or the model-inferred value take precedence. + // Hardcoding 'max_tokens' breaks providers like MiMo that require + // 'max_completion_tokens', even when accessed through a local proxy. + ...(remoteModelInferredConfig?.maxTokensField !== undefined + ? { maxTokensField: remoteModelInferredConfig.maxTokensField } + : { maxTokensField: 'max_tokens' as const }), // Local proxies (e.g. key routers like grouter) may forward to remote // reasoning models that require reasoning_content. Preserve the // model-inferred config for reasoning fields so local routing doesn't