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
6 changes: 6 additions & 0 deletions packages/core/src/core/openaiContentGenerator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
DashScopeOpenAICompatibleProvider,
DeepSeekOpenAICompatibleProvider,
ModelScopeOpenAICompatibleProvider,
MiMoOpenAICompatibleProvider,
MiniMaxOpenAICompatibleProvider,
MistralOpenAICompatibleProvider,
OpenRouterOpenAICompatibleProvider,
Expand All @@ -29,6 +30,7 @@ export {
type OpenAICompatibleProvider,
DashScopeOpenAICompatibleProvider,
DeepSeekOpenAICompatibleProvider,
MiMoOpenAICompatibleProvider,
MiniMaxOpenAICompatibleProvider,
MistralOpenAICompatibleProvider,
OpenRouterOpenAICompatibleProvider,
Expand Down Expand Up @@ -76,6 +78,10 @@ export function determineProvider(
);
}

if (MiMoOpenAICompatibleProvider.isMiMoProvider(config)) {
return new MiMoOpenAICompatibleProvider(contentGeneratorConfig, cliConfig);
}

// Check for OpenRouter provider
if (OpenRouterOpenAICompatibleProvider.isOpenRouterProvider(config)) {
return new OpenRouterOpenAICompatibleProvider(
Expand Down
85 changes: 85 additions & 0 deletions packages/core/src/core/openaiContentGenerator/pipeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,91 @@ describe('ContentGenerationPipeline', () => {
);
});

it('should apply provider request context overrides', async () => {
Comment thread
DragonnZhang marked this conversation as resolved.
const request: GenerateContentParameters = {
model: 'test-model',
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
};
const userPromptId = 'test-prompt-id';
const mockMessages = [
{ role: 'user', content: 'Hello' },
] as OpenAI.Chat.ChatCompletionMessageParam[];
const mockOpenAIResponse = {
id: 'response-id',
choices: [
{ message: { content: 'Hello response' }, finish_reason: 'stop' },
],
created: Date.now(),
model: 'test-model',
} as OpenAI.Chat.ChatCompletion;
const mockGeminiResponse = new GenerateContentResponse();

mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({
splitToolMedia: true,
});
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue(
mockMessages,
);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
mockGeminiResponse,
);
(mockClient.chat.completions.create as Mock).mockResolvedValue(
mockOpenAIResponse,
);

await pipeline.execute(request, userPromptId);

expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith(
request,
expect.objectContaining({
splitToolMedia: true,
}),
);
});

it('should let provider request context overrides take precedence over content generator config', async () => {
const request: GenerateContentParameters = {
model: 'test-model',
contents: [{ parts: [{ text: 'Hello' }], role: 'user' }],
};
const userPromptId = 'test-prompt-id';
const mockMessages = [
{ role: 'user', content: 'Hello' },
] as OpenAI.Chat.ChatCompletionMessageParam[];
const mockOpenAIResponse = {
id: 'response-id',
choices: [
{ message: { content: 'Hello response' }, finish_reason: 'stop' },
],
created: Date.now(),
model: 'test-model',
} as OpenAI.Chat.ChatCompletion;
const mockGeminiResponse = new GenerateContentResponse();

mockContentGeneratorConfig.splitToolMedia = true;
mockProvider.getRequestContextOverrides = vi.fn().mockReturnValue({
splitToolMedia: false,
});
(mockConverter.convertGeminiRequestToOpenAI as Mock).mockReturnValue(
mockMessages,
);
(mockConverter.convertOpenAIResponseToGemini as Mock).mockReturnValue(
mockGeminiResponse,
);
(mockClient.chat.completions.create as Mock).mockResolvedValue(
mockOpenAIResponse,
);

await pipeline.execute(request, userPromptId);

expect(mockConverter.convertGeminiRequestToOpenAI).toHaveBeenCalledWith(
request,
expect.objectContaining({
splitToolMedia: false,
}),
);
});

it('should fall back to configured model when request.model is empty', async () => {
// Arrange — empty model string is falsy, should fall back to contentGeneratorConfig.model
const request: GenerateContentParameters = {
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/core/openaiContentGenerator/pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,8 @@ export class ContentGenerationPipeline {
isStreaming: boolean,
): RequestContext {
const effectiveModel = request.model || this.contentGeneratorConfig.model;
const providerOverrides =
this.config.provider.getRequestContextOverrides?.() ?? {};
const toolCallParser = isStreaming
? new StreamingToolCallParser()
: undefined;
Expand All @@ -558,7 +560,10 @@ export class ContentGenerationPipeline {
model: effectiveModel,
modalities: this.contentGeneratorConfig.modalities ?? {},
startTime: Date.now(),
splitToolMedia: this.contentGeneratorConfig.splitToolMedia ?? false,
splitToolMedia:
providerOverrides.splitToolMedia ??
this.contentGeneratorConfig.splitToolMedia ??
false,
...(toolCallParser ? { toolCallParser } : {}),
...(responseParsingOptions ? { responseParsingOptions } : {}),
...(taggedThinkingParser ? { taggedThinkingParser } : {}),
Expand Down
28 changes: 2 additions & 26 deletions packages/core/src/core/openaiContentGenerator/provider/deepseek.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@
import type OpenAI from 'openai';
import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import type { ExtendedChatCompletionAssistantMessageParam } from '../converter.js';
import { DefaultOpenAICompatibleProvider } from './default.js';
import type { GenerateContentConfig } from '@google/genai';
import { ensureReasoningContentOnAssistantMessage } from './utils.js';

/**
* Hostname-only check used to decide whether `reasoning.effort` should be
Expand Down Expand Up @@ -108,7 +108,7 @@ export class DeepSeekOpenAICompatibleProvider extends DefaultOpenAICompatiblePro

const messages = reshaped.messages.map((message) => {
const flattened = flattenContentParts(message);
return ensureReasoningContentOnToolCalls(flattened);
return ensureReasoningContentOnAssistantMessage(flattened);
});

return {
Expand Down Expand Up @@ -216,27 +216,3 @@ function translateReasoningEffort(
}
return next as unknown as OpenAI.Chat.ChatCompletionCreateParams;
}

// DeepSeek's thinking mode requires reasoning_content to be replayed on every
// prior assistant turn, including ones without tool_calls. The model may
// legitimately return a turn without reasoning text, so the field can be
// missing when we rebuild the request. Send an empty string in that case so
// the API contract is satisfied. https://github.com/QwenLM/qwen-code/issues/3695
function ensureReasoningContentOnToolCalls(
message: OpenAI.Chat.ChatCompletionMessageParam,
): OpenAI.Chat.ChatCompletionMessageParam {
if (message.role !== 'assistant') {
return message;
}
const extended = message as ExtendedChatCompletionAssistantMessageParam;
if (
typeof extended.reasoning_content === 'string' &&
extended.reasoning_content.length > 0
) {
return message;
}
return {
...extended,
reasoning_content: '',
} as OpenAI.Chat.ChatCompletionMessageParam;
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ export { DeepSeekOpenAICompatibleProvider } from './deepseek.js';
export { OpenRouterOpenAICompatibleProvider } from './openrouter.js';
export { MiniMaxOpenAICompatibleProvider } from './minimax.js';
export { MistralOpenAICompatibleProvider } from './mistral.js';
export { MiMoOpenAICompatibleProvider } from './mimo.js';
export { DefaultOpenAICompatibleProvider } from './default.js';
export type {
OpenAICompatibleProvider,
Expand Down
184 changes: 184 additions & 0 deletions packages/core/src/core/openaiContentGenerator/provider/mimo.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it, vi } from 'vitest';
import type OpenAI from 'openai';
import type { Config } from '../../../config/config.js';
import type { ContentGeneratorConfig } from '../../contentGenerator.js';
import { determineProvider } from '../index.js';
import { MiMoOpenAICompatibleProvider } from './mimo.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://token-plan-cn.xiaomimimo.com/v1',
model: 'mimo-v2.5-pro',
...overrides,
} as ContentGeneratorConfig;
}

describe('MiMoOpenAICompatibleProvider', () => {
it('is selected for Xiaomi MiMo hostnames', () => {
const provider = determineProvider(
createProviderConfig(),
createCliConfig(),
);

expect(provider).toBeInstanceOf(MiMoOpenAICompatibleProvider);
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] Missing test for determineProvider ordering when both MiMo model name and OpenRouter hostname match.

A user configuring baseUrl: 'https://openrouter.ai/api/v1' + model: 'mimo-v2.5-pro' currently gets the MiMo provider (checked before OpenRouter in determineProvider). No test asserts this priority. If someone later reorders the checks, users routing MiMo models through OpenRouter would silently get the wrong provider — losing reasoning_content injection and splitToolMedia: true.

Consider adding:

it('is selected over OpenRouter when both hostname and model match', () => {
  const provider = determineProvider(
    createProviderConfig({
      baseUrl: 'https://openrouter.ai/v1',
      model: 'mimo-v2.5-pro',
    }),
    createCliConfig(),
  );
  expect(provider).toBeInstanceOf(MiMoOpenAICompatibleProvider);
});

— qwen-latest-series-invite-beta-v34 via Qwen Code /review

});

it('is selected for official API hostnames', () => {
const provider = determineProvider(
createProviderConfig({
baseUrl: 'https://api.xiaomimimo.com/v1',
model: 'custom-model-alias',
}),
createCliConfig(),
);

expect(provider).toBeInstanceOf(MiMoOpenAICompatibleProvider);
});

it('is selected for MiMo model names behind custom gateways', () => {
const provider = determineProvider(
createProviderConfig({
baseUrl: 'https://gateway.example.com/v1',
model: 'MiMo-V2.5-Pro',
}),
createCliConfig(),
);

expect(provider).toBeInstanceOf(MiMoOpenAICompatibleProvider);
});

it('does not match hostile hostnames containing xiaomimimo.com', () => {
const provider = determineProvider(
createProviderConfig({
baseUrl: 'https://api.xiaomimimo.com.evil.example/v1',
model: 'gpt-4o',
}),
createCliConfig(),
);

expect(provider).not.toBeInstanceOf(MiMoOpenAICompatibleProvider);
});

it('splits tool-result media by default for strict MiMo chat requests', () => {
const provider = determineProvider(
createProviderConfig(),
createCliConfig(),
);

expect(provider.getRequestContextOverrides?.()).toEqual({
splitToolMedia: true,
});
});

it('preserves an explicit splitToolMedia override', () => {
const provider = determineProvider(
createProviderConfig({ splitToolMedia: false }),
createCliConfig(),
);

expect(provider.getRequestContextOverrides?.()).toEqual({
splitToolMedia: false,
});
});

it('injects empty reasoning_content on tool-calling assistant turns missing it', () => {
const provider = determineProvider(
createProviderConfig(),
createCliConfig(),
);
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'mimo-v2.5-pro',
messages: [
{ role: 'user', content: 'list markdown files' },
{
role: 'assistant',
content: '',
tool_calls: [
{
id: 'call_1',
type: 'function',
function: { name: 'glob', arguments: '{"pattern":"**/*.md"}' },
},
],
},
{ role: 'tool', tool_call_id: 'call_1', content: 'Found 2 files' },
],
};

const result = provider.buildRequest(request, 'prompt-123');
const assistant = result.messages?.[1] as {
reasoning_content?: string;
};

expect(assistant.reasoning_content).toBe('');
});

it('preserves existing reasoning_content on tool-calling assistant turns', () => {
const provider = determineProvider(
createProviderConfig(),
createCliConfig(),
);
const request = {
model: 'mimo-v2.5-pro',
messages: [
{ role: 'user' as const, content: 'list markdown files' },
{
role: 'assistant' as const,
content: '',
reasoning_content: 'I should search the repository.',
tool_calls: [
{
id: 'call_1',
type: 'function' as const,
function: { name: 'glob', arguments: '{"pattern":"**/*.md"}' },
},
],
},
],
} as unknown as OpenAI.Chat.ChatCompletionCreateParams;

const result = provider.buildRequest(request, 'prompt-123');
const assistant = result.messages?.[1] as {
reasoning_content?: string;
};

expect(assistant.reasoning_content).toBe('I should search the repository.');
});

it('injects empty reasoning_content on assistant turns without tool calls', () => {
const provider = determineProvider(
createProviderConfig(),
createCliConfig(),
);
const request: OpenAI.Chat.ChatCompletionCreateParams = {
model: 'mimo-v2.5-pro',
messages: [
{ role: 'user', content: 'hi' },
{ role: 'assistant', content: 'hello' },
],
};

const result = provider.buildRequest(request, 'prompt-123');
const assistant = result.messages?.[1] as {
reasoning_content?: string;
};

expect(assistant.reasoning_content).toBe('');
});
});
Loading
Loading