From 6b0c8a30658d5ebfd52b481af04c13813966c4d4 Mon Sep 17 00:00:00 2001 From: "jinjing.zzj" Date: Sat, 5 Sep 2026 04:47:34 +0800 Subject: [PATCH] fix(core): strip reasoning_content from Cerebras requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cerebras' OpenAI-compatible endpoint rejects the non-standard `messages[].reasoning_content` field on input with HTTP 400 (`wrong_api_format`). `determineProvider()` has no Cerebras branch, so `api.cerebras.ai` falls through to the default provider, which ships the field verbatim — every multi-turn request that replays a thinking turn fails with `400 status code (no body)`. Add a hostname-detected Cerebras provider subclass that removes `reasoning_content` at the outbound request boundary only, matching the existing Mistral handling. Session history stays intact, so DeepSeek/Qwen thinking-mode replay (which requires the field) is unaffected. The strip helper moves from `mistral.ts` to the shared `provider/utils.ts` unchanged. Detection is hostname-only: Cerebras serves third-party model names (qwen-3.8-27b, gpt-oss-120b, llama-*), so a model-name fallback would misroute other providers' models. Deleting the field rather than renaming it to Cerebras' accepted `reasoning` input field matches the proven Mistral pattern; the trade-off (no cross-turn thinking replay) is the same already-accepted behavior Mistral has. Fixes #11045 Co-authored-by: Qwen-Coder Patrol-Run: qwen-issue-patrol/jmtnduz2xjf --- .../src/core/openaiContentGenerator/index.ts | 10 + .../provider/cerebras.test.ts | 269 ++++++++++++++++++ .../provider/cerebras.ts | 56 ++++ .../openaiContentGenerator/provider/index.ts | 1 + .../provider/mistral.ts | 13 +- .../openaiContentGenerator/provider/utils.ts | 16 ++ 6 files changed, 353 insertions(+), 12 deletions(-) create mode 100644 packages/core/src/core/openaiContentGenerator/provider/cerebras.test.ts create mode 100644 packages/core/src/core/openaiContentGenerator/provider/cerebras.ts diff --git a/packages/core/src/core/openaiContentGenerator/index.ts b/packages/core/src/core/openaiContentGenerator/index.ts index bc4035575e8..8079b96bbef 100644 --- a/packages/core/src/core/openaiContentGenerator/index.ts +++ b/packages/core/src/core/openaiContentGenerator/index.ts @@ -18,6 +18,7 @@ import { MiMoOpenAICompatibleProvider, MiniMaxOpenAICompatibleProvider, MistralOpenAICompatibleProvider, + CerebrasOpenAICompatibleProvider, type OpenAICompatibleProvider, DefaultOpenAICompatibleProvider, } from './provider/index.js'; @@ -33,6 +34,7 @@ export { MiMoOpenAICompatibleProvider, MiniMaxOpenAICompatibleProvider, MistralOpenAICompatibleProvider, + CerebrasOpenAICompatibleProvider, } from './provider/index.js'; export { OpenAIContentConverter } from './converter.js'; @@ -109,6 +111,14 @@ export function determineProvider( ); } + // Check for Cerebras provider + if (CerebrasOpenAICompatibleProvider.isCerebrasProvider(config)) { + return new CerebrasOpenAICompatibleProvider( + contentGeneratorConfig, + cliConfig, + ); + } + // Default provider for standard OpenAI-compatible APIs return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig); } diff --git a/packages/core/src/core/openaiContentGenerator/provider/cerebras.test.ts b/packages/core/src/core/openaiContentGenerator/provider/cerebras.test.ts new file mode 100644 index 00000000000..ef5911fb95f --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/cerebras.test.ts @@ -0,0 +1,269 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import type OpenAI from 'openai'; +import type { GenerateContentParameters } from '@google/genai'; +import type { Config } from '../../../config/config.js'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import { determineProvider } from '../index.js'; +import { OpenAIContentGenerator } from '../openaiContentGenerator.js'; +import { CerebrasOpenAICompatibleProvider } from './cerebras.js'; + +function createCliConfig(): Config { + return { + getCliVersion: vi.fn().mockReturnValue('1.0.0'), + getProxy: vi.fn().mockReturnValue(undefined), + } as unknown as Config; +} + +function createProviderConfig( + overrides: Partial, +): ContentGeneratorConfig { + return { + apiKey: 'test-api-key', + baseUrl: 'https://api.cerebras.ai/v1', + model: 'qwen-3.8-27b', + ...overrides, + } as ContentGeneratorConfig; +} + +function createReasoningRequest(): OpenAI.Chat.ChatCompletionCreateParams { + return { + model: 'qwen-3.8-27b', + messages: [ + { role: 'user', content: 'test' }, + { + role: 'assistant', + content: 'Hey! How can I help?', + reasoning_content: 'The user said test.', + } as OpenAI.Chat.ChatCompletionAssistantMessageParam & { + reasoning_content: string; + }, + { role: 'user', content: 'follow-up question' }, + ], + max_tokens: 1000, + }; +} + +describe('Cerebras provider outbound compatibility filtering', () => { + it('strips reasoning_content from outgoing requests for api.cerebras.ai without mutating the source history', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://api.cerebras.ai/v1', + model: 'qwen-3.8-27b', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect(result.messages?.[1]).toEqual({ + role: 'assistant', + content: 'Hey! How can I help?', + }); + expect( + (originalRequest.messages[1] as { reasoning_content?: string }) + .reasoning_content, + ).toBe('The user said test.'); + }); + + it('strips reasoning_content for Cerebras subdomains', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://proxy.api.cerebras.ai/v1', + model: 'gpt-oss-120b', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect(result.messages?.[1]).toEqual({ + role: 'assistant', + content: 'Hey! How can I help?', + }); + }); + + it('does not treat hostile hostnames containing api.cerebras.ai as Cerebras', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://api.cerebras.ai.evil.example/v1', + model: 'gpt-4o', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect( + (result.messages?.[1] as { reasoning_content?: string }) + .reasoning_content, + ).toBe('The user said test.'); + }); + + it('preserves reasoning_content for non-Cerebras OpenAI-compatible providers', () => { + const originalRequest = createReasoningRequest(); + const provider = determineProvider( + createProviderConfig({ + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-4o', + }), + createCliConfig(), + ); + + const result = provider.buildRequest(originalRequest, 'prompt-123'); + + expect( + (result.messages?.[1] as { reasoning_content?: string }) + .reasoning_content, + ).toBe('The user said test.'); + }); +}); + +describe('multi-turn against a Cerebras-like strict endpoint (issue #11045)', () => { + /** + * Local stand-in for Cerebras: accepts OpenAI-compatible chat completions + * but rejects any request whose body carries the `reasoning_content` + * field, with the same payload api.cerebras.ai returns. + */ + let server: http.Server; + let baseUrl: string; + let receivedBodies: Array>; + + beforeAll(async () => { + // The generator's constructor builds undici-backed fetch options + // synchronously; production preloads undici in createContentGenerator. + const { preloadRuntimeFetchModule } = await import( + '../../../utils/runtimeFetchOptions.js' + ); + await preloadRuntimeFetchModule(); + + receivedBodies = []; + server = http.createServer((req, res) => { + let raw = ''; + req.on('data', (chunk) => { + raw += chunk; + }); + req.on('end', () => { + const body = JSON.parse(raw) as Record; + receivedBodies.push(body); + if (raw.includes('reasoning_content')) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + message: + "messages.1.assistant.reasoning_content: property 'messages.1.assistant.reasoning_content' is unsupported", + type: 'invalid_request_error', + param: 'validation_error', + code: 'wrong_api_format', + }), + ); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end( + JSON.stringify({ + id: 'chatcmpl-test', + object: 'chat.completion', + created: 1757000000, + model: body['model'], + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'Sure, go ahead.' }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 4, + total_tokens: 14, + }, + }), + ); + }); + }); + await new Promise((resolve) => { + server.listen(0, '127.0.0.1', resolve); + }); + const { port } = server.address() as AddressInfo; + baseUrl = `http://127.0.0.1:${port}/v1`; + }); + + afterAll(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('replays reasoning history on follow-up turns without shipping reasoning_content', async () => { + // Wire the Cerebras provider at the local stand-in endpoint; detection + // by hostname is covered above. The generator runs the real path: + // session history -> converter -> provider boundary -> wire. + const providerConfig = createProviderConfig({ + baseUrl, + model: 'qwen-3.8-27b', + }); + const cliConfig = createCliConfig(); + const generator = new OpenAIContentGenerator( + providerConfig, + cliConfig, + new CerebrasOpenAICompatibleProvider(providerConfig, cliConfig), + ); + + // Turn 1 — no history, succeeds. + const turn1: GenerateContentParameters = { + model: 'qwen-3.8-27b', + contents: [{ role: 'user', parts: [{ text: 'test' }] }], + }; + const response1 = await generator.generateContent(turn1, 'prompt-1'); + expect(response1.candidates?.[0]?.content?.parts?.[0]).toMatchObject({ + text: 'Sure, go ahead.', + }); + + // Turn 2 — history carries the model's prior thinking as a thought part, + // exactly what the session history holds after a thinking turn. + const turn2: GenerateContentParameters = { + model: 'qwen-3.8-27b', + contents: [ + { role: 'user', parts: [{ text: 'test' }] }, + { + role: 'model', + parts: [ + { text: 'The user said test.', thought: true }, + { text: 'Hey! How can I help?' }, + ], + }, + { role: 'user', parts: [{ text: 'follow-up question' }] }, + ], + }; + const response2 = await generator.generateContent(turn2, 'prompt-2'); + expect(response2.candidates?.[0]?.content?.parts?.[0]).toMatchObject({ + text: 'Sure, go ahead.', + }); + + // The strict endpoint accepted both turns, and the follow-up request + // that reached the wire never carried reasoning_content. + const followUp = receivedBodies[1] as { + messages?: Array>; + }; + expect(followUp.messages).toBeDefined(); + const assistantOnWire = followUp.messages?.find( + (message) => message['role'] === 'assistant', + ); + expect(assistantOnWire).toMatchObject({ + role: 'assistant', + content: 'Hey! How can I help?', + }); + expect(assistantOnWire).not.toHaveProperty('reasoning_content'); + }); +}); diff --git a/packages/core/src/core/openaiContentGenerator/provider/cerebras.ts b/packages/core/src/core/openaiContentGenerator/provider/cerebras.ts new file mode 100644 index 00000000000..d0e09882c68 --- /dev/null +++ b/packages/core/src/core/openaiContentGenerator/provider/cerebras.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type OpenAI from 'openai'; +import type { ContentGeneratorConfig } from '../../contentGenerator.js'; +import { DefaultOpenAICompatibleProvider } from './default.js'; +import { stripReasoningContent } from './utils.js'; + +const CEREBRAS_API_HOST = 'api.cerebras.ai'; + +/** + * Hostname-only detection: Cerebras serves third-party model names + * (`qwen-3.8-27b`, `gpt-oss-120b`, `llama-*`), so a model-name fallback + * would misroute other providers' models. + */ +export function isCerebrasProvider(config: ContentGeneratorConfig): boolean { + const baseUrl = config.baseUrl ?? ''; + if (!baseUrl) return false; + + try { + const hostname = new URL(baseUrl).hostname.toLowerCase(); + return ( + hostname === CEREBRAS_API_HOST || + hostname.endsWith(`.${CEREBRAS_API_HOST}`) + ); + } catch { + return false; + } +} + +/** + * Cerebras' OpenAI-compatible endpoint rejects the non-standard + * `messages[].reasoning_content` field on input with HTTP 400 + * (`wrong_api_format`), so every multi-turn request that replays a + * thinking turn fails (issue #11045). Keep shared conversation history + * intact and remove the field only at the outbound request boundary, + * matching the Mistral handling. + */ +export class CerebrasOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider { + static isCerebrasProvider = isCerebrasProvider; + + override buildRequest( + request: OpenAI.Chat.ChatCompletionCreateParams, + userPromptId: string, + ): OpenAI.Chat.ChatCompletionCreateParams { + const baseRequest = super.buildRequest(request, userPromptId); + + return { + ...baseRequest, + messages: baseRequest.messages.map(stripReasoningContent), + }; + } +} diff --git a/packages/core/src/core/openaiContentGenerator/provider/index.ts b/packages/core/src/core/openaiContentGenerator/provider/index.ts index 35415fcc3da..4cced244bf5 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/index.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/index.ts @@ -4,6 +4,7 @@ export { DeepSeekOpenAICompatibleProvider } from './deepseek.js'; export { ZaiOpenAICompatibleProvider } from './zai.js'; export { MiniMaxOpenAICompatibleProvider } from './minimax.js'; export { MistralOpenAICompatibleProvider } from './mistral.js'; +export { CerebrasOpenAICompatibleProvider } from './cerebras.js'; export { MiMoOpenAICompatibleProvider } from './mimo.js'; export { DefaultOpenAICompatibleProvider } from './default.js'; export type { diff --git a/packages/core/src/core/openaiContentGenerator/provider/mistral.ts b/packages/core/src/core/openaiContentGenerator/provider/mistral.ts index ba60cf81538..dcfe01c51fd 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/mistral.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/mistral.ts @@ -7,6 +7,7 @@ import type OpenAI from 'openai'; import type { ContentGeneratorConfig } from '../../contentGenerator.js'; import { DefaultOpenAICompatibleProvider } from './default.js'; +import { stripReasoningContent } from './utils.js'; const MISTRAL_API_HOST = 'api.mistral.ai'; const MISTRAL_MODEL_MARKERS = [ @@ -60,15 +61,3 @@ export class MistralOpenAICompatibleProvider extends DefaultOpenAICompatibleProv }; } } - -function stripReasoningContent( - message: OpenAI.Chat.ChatCompletionMessageParam, -): OpenAI.Chat.ChatCompletionMessageParam { - if (!('reasoning_content' in message)) { - return message; - } - - const next = { ...(message as unknown as Record) }; - delete next['reasoning_content']; - return next as unknown as OpenAI.Chat.ChatCompletionMessageParam; -} diff --git a/packages/core/src/core/openaiContentGenerator/provider/utils.ts b/packages/core/src/core/openaiContentGenerator/provider/utils.ts index a09435e3afe..a9ca2cb37c5 100644 --- a/packages/core/src/core/openaiContentGenerator/provider/utils.ts +++ b/packages/core/src/core/openaiContentGenerator/provider/utils.ts @@ -27,3 +27,19 @@ export function ensureReasoningContentOnAssistantMessage( reasoning_content: '', } as OpenAI.Chat.ChatCompletionMessageParam; } + +// Some strict OpenAI-compatible endpoints (Mistral, Cerebras) reject the +// non-standard `reasoning_content` field on input with HTTP 400. Shared +// conversation history must stay intact for providers that require the +// replay; remove the field only at the outbound request boundary. +export function stripReasoningContent( + message: OpenAI.Chat.ChatCompletionMessageParam, +): OpenAI.Chat.ChatCompletionMessageParam { + if (!('reasoning_content' in message)) { + return message; + } + + const next = { ...(message as unknown as Record) }; + delete next['reasoning_content']; + return next as unknown as OpenAI.Chat.ChatCompletionMessageParam; +}