Skip to content
Merged
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
10 changes: 10 additions & 0 deletions packages/core/src/core/openaiContentGenerator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
MiMoOpenAICompatibleProvider,
MiniMaxOpenAICompatibleProvider,
MistralOpenAICompatibleProvider,
CerebrasOpenAICompatibleProvider,
type OpenAICompatibleProvider,
DefaultOpenAICompatibleProvider,
} from './provider/index.js';
Expand All @@ -33,6 +34,7 @@ export {
MiMoOpenAICompatibleProvider,
MiniMaxOpenAICompatibleProvider,
MistralOpenAICompatibleProvider,
CerebrasOpenAICompatibleProvider,
} from './provider/index.js';

export { OpenAIContentConverter } from './converter.js';
Expand Down Expand Up @@ -109,6 +111,14 @@ export function determineProvider(
);
}

// Check for Cerebras provider
if (CerebrasOpenAICompatibleProvider.isCerebrasProvider(config)) {
Comment thread
yiliang114 marked this conversation as resolved.
return new CerebrasOpenAICompatibleProvider(
contentGeneratorConfig,
cliConfig,
);
}

// Default provider for standard OpenAI-compatible APIs
return new DefaultOpenAICompatibleProvider(contentGeneratorConfig, cliConfig);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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>,
): 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<Record<string, unknown>>;

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<string, unknown>;
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<void>((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<void>((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<Record<string, unknown>>;
};
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');
});
});
Original file line number Diff line number Diff line change
@@ -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}`)
);
Comment on lines +24 to +28

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: isCerebrasProvider is a near-verbatim copy of isMistralHostname — same URL parse, lowercasing, exact-or-dot-suffix match, same try/catch — differing only in the host constant, and the buildRequest override this PR adds is byte-for-byte identical to Mistral's. The same matcher shape is hand-copied across roughly eight provider files. This PR already consolidated stripReasoningContent into provider/utils.ts; the hostname matcher belongs in that same shared home. The matcher encodes security-relevant semantics — exact host or dot-prefixed subdomain, rejecting lookalikes like api.cerebras.ai.evil.example, which this PR's own hostile-hostname test pins — so with ~8 hand-copies, any hardening of host matching (trailing-dot hostnames, IPv6 literals, IDN) must be applied to every copy in lockstep, and missing one silently misroutes or over-strips for that provider.

Witness:

cerebras buildRequest == mistral buildRequest: True
matcher sweep — new URL(...).hostname.toLowerCase() + exact-or-dot-suffix + try/catch:
  8 hand-copies total: mistral.ts, cerebras.ts, deepseek.ts, zai.ts, mimo.ts,
  openrouter.ts (regex sweep) + modelscope.ts, minimax.ts (direct read)

Extract a shared helper next to stripReasoningContent in provider/utils.ts and use it from cerebras.ts and mistral.ts — the two call sites this diff touches:

export function isProviderApiHost(
  config: ContentGeneratorConfig,
  host: string,
): boolean {
  const baseUrl = config.baseUrl ?? '';
  if (!baseUrl) return false;
  try {
    const hostname = new URL(baseUrl).hostname.toLowerCase();
    return hostname === host || hostname.endsWith(`.${host}`);
  } catch {
    return false;
  }
}

The rewiring must preserve Mistral's hostname-OR-model-marker routing — if (isMistralHostname(config)) return true; followed by the MISTRAL_MODEL_MARKERS check (mistral.ts:37-41). If applied, please confirm the extraction keeps the matching semantics: cerebras.test.ts's "does not treat hostile hostnames containing api.cerebras.ai as Cerebras" and "strips reasoning_content for Cerebras subdomains" pin the exact-or-dot-suffix semantics, and either goes red if the shared helper regresses to hostname.includes(host).

中文说明

R1-3:isCerebrasProviderisMistralHostname 几乎逐字相同——同样的 URL 解析、小写化、精确或点前缀子域名匹配、同样的 try/catch——只有 host 常量不同;本 PR 新增的 buildRequest 重写也与 Mistral 的逐字节一致。同一匹配逻辑在约 8 个 provider 文件中被手工复制。本 PR 已经把 stripReasoningContent 收敛到 provider/utils.ts;hostname 匹配器也应该放到同一个共享位置。该匹配器承载安全相关语义——精确匹配或点前缀子域名、拒绝形似域名(如 api.cerebras.ai.evil.example,本 PR 自己的恶意域名测试固定了这一点)——因此在约 8 份手工副本的情况下,任何对主机匹配的加固(尾点域名、IPv6 字面量、IDN)都必须同步应用到每一份,漏掉任何一份都会让对应 provider 悄悄误路由或过度 strip。

建议:在 provider/utils.ts 中、stripReasoningContent 旁边抽取共享辅助函数,并在 cerebras.tsmistral.ts——即本 diff 触及的两个调用点——中使用(代码见英文部分)。

修复约束:重接线必须保留 Mistral 的「hostname 或模型标记」路由——if (isMistralHostname(config)) return true; 之后是 MISTRAL_MODEL_MARKERS 检查(mistral.ts:37-41)。修复见证:如果应用此改动,请确认抽取没有改变匹配语义——cerebras.test.ts 的「does not treat hostile hostnames containing api.cerebras.ai as Cerebras」与「strips reasoning_content for Cerebras subdomains」固定了精确或点前缀后缀语义;若共享 helper 退化为 hostname.includes(host),这两个用例会变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] R1-3: isCerebrasProvider is a near-verbatim copy of isMistralHostname — same URL parse, lowercasing, exact-or-dot-suffix match, same try/catch — differing only in the host constant, and the buildRequest override this PR adds is byte-for-byte identical to Mistral's. The same matcher shape is hand-copied across roughly eight provider files. This PR already consolidated stripReasoningContent into provider/utils.ts; the hostname matcher belongs in that same shared home. The matcher encodes security-relevant semantics — exact host or dot-prefixed subdomain, rejecting lookalikes like api.cerebras.ai.evil.example, which this PR's own hostile-hostname test pins — so with ~8 hand-copies, any hardening of host matching (trailing-dot hostnames, IPv6 literals, IDN) must be applied to every copy in lockstep, and missing one silently misroutes or over-strips for that provider.

Witness:

cerebras buildRequest == mistral buildRequest: True
matcher sweep — new URL(...).hostname.toLowerCase() + exact-or-dot-suffix + try/catch:
  8 hand-copies total: mistral.ts, cerebras.ts, deepseek.ts, zai.ts, mimo.ts,
  openrouter.ts (regex sweep) + modelscope.ts, minimax.ts (direct read)

Extract a shared helper next to stripReasoningContent in provider/utils.ts and use it from cerebras.ts and mistral.ts — the two call sites this diff touches:

export function isProviderApiHost(
  config: ContentGeneratorConfig,
  host: string,
): boolean {
  const baseUrl = config.baseUrl ?? '';
  if (!baseUrl) return false;
  try {
    const hostname = new URL(baseUrl).hostname.toLowerCase();
    return hostname === host || hostname.endsWith(`.${host}`);
  } catch {
    return false;
  }
}

The rewiring must preserve Mistral's hostname-OR-model-marker routing — if (isMistralHostname(config)) return true; followed by the MISTRAL_MODEL_MARKERS check (mistral.ts:37-41). If applied, please confirm the extraction keeps the matching semantics: cerebras.test.ts's "does not treat hostile hostnames containing api.cerebras.ai as Cerebras" and "strips reasoning_content for Cerebras subdomains" pin the exact-or-dot-suffix semantics, and either goes red if the shared helper regresses to hostname.includes(host).

中文说明

R1-3:isCerebrasProviderisMistralHostname 几乎逐字相同——同样的 URL 解析、小写化、精确或点前缀子域名匹配、同样的 try/catch——只有 host 常量不同;本 PR 新增的 buildRequest 重写也与 Mistral 的逐字节一致。同一匹配逻辑在约 8 个 provider 文件中被手工复制。本 PR 已经把 stripReasoningContent 收敛到 provider/utils.ts;hostname 匹配器也应该放到同一个共享位置。该匹配器承载安全相关语义——精确匹配或点前缀子域名、拒绝形似域名(如 api.cerebras.ai.evil.example,本 PR 自己的恶意域名测试固定了这一点)——因此在约 8 份手工副本的情况下,任何对主机匹配的加固(尾点域名、IPv6 字面量、IDN)都必须同步应用到每一份,漏掉任何一份都会让对应 provider 悄悄误路由或过度 strip。

建议:在 provider/utils.ts 中、stripReasoningContent 旁边抽取共享辅助函数,并在 cerebras.tsmistral.ts——即本 diff 触及的两个调用点——中使用(代码见英文部分)。

修复约束:重接线必须保留 Mistral 的「hostname 或模型标记」路由——if (isMistralHostname(config)) return true; 之后是 MISTRAL_MODEL_MARKERS 检查(mistral.ts:37-41)。修复见证:如果应用此改动,请确认抽取没有改变匹配语义——cerebras.test.ts 的「does not treat hostile hostnames containing api.cerebras.ai as Cerebras」与「strips reasoning_content for Cerebras subdomains」固定了精确或点前缀后缀语义;若共享 helper 退化为 hostname.includes(host),这两个用例会变红。

— qwen3.8-max via Qwen Code /review (v0.23.0)

} 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),
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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<string, unknown>) };
delete next['reasoning_content'];
return next as unknown as OpenAI.Chat.ChatCompletionMessageParam;
}
Loading
Loading