Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
42 changes: 42 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,48 @@ 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 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
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('does not add reasoning_content to 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).toBeUndefined();
});
});
87 changes: 87 additions & 0 deletions packages/core/src/core/openaiContentGenerator/provider/mimo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/

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';

export function isMiMoProvider(
contentGeneratorConfig: ContentGeneratorConfig,
): boolean {
const baseUrl = contentGeneratorConfig.baseUrl ?? '';
if (baseUrl) {
try {
const hostname = new URL(baseUrl).hostname.toLowerCase();
if (
hostname === 'xiaomimimo.com' ||
hostname.endsWith('.xiaomimimo.com')
) {
return true;
}
} catch {
// Invalid custom URLs fall through to model-name detection.
}
}

const model = contentGeneratorConfig.model ?? '';
return model.toLowerCase().startsWith('mimo-');
}

export class MiMoOpenAICompatibleProvider extends DefaultOpenAICompatibleProvider {
constructor(
contentGeneratorConfig: ContentGeneratorConfig,
cliConfig: Config,
) {
super(contentGeneratorConfig, cliConfig);
}

static isMiMoProvider = isMiMoProvider;

override buildRequest(
request: OpenAI.Chat.ChatCompletionCreateParams,
userPromptId: string,
): OpenAI.Chat.ChatCompletionCreateParams {
const baseRequest = super.buildRequest(request, userPromptId);
if (!baseRequest.messages?.length) {
return baseRequest;
}

return {
...baseRequest,
messages: baseRequest.messages.map(ensureReasoningContentOnToolCalls),
};
}

getRequestContextOverrides(): { splitToolMedia?: boolean } {
Comment thread
DragonnZhang marked this conversation as resolved.
Outdated
return {
splitToolMedia: this.contentGeneratorConfig.splitToolMedia ?? true,
};
}
}

function ensureReasoningContentOnToolCalls(
message: OpenAI.Chat.ChatCompletionMessageParam,
): OpenAI.Chat.ChatCompletionMessageParam {
if (message.role !== 'assistant') {
return message;
}
Comment thread
DragonnZhang marked this conversation as resolved.
Outdated

const assistant = message as ExtendedChatCompletionAssistantMessageParam;
if (!assistant.tool_calls?.length) {
return message;
}

if (typeof assistant.reasoning_content === 'string') {
Comment thread
DragonnZhang marked this conversation as resolved.
Outdated
return message;
}

return {
...assistant,
reasoning_content: '',
} as OpenAI.Chat.ChatCompletionMessageParam;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@ import type { GenerateContentConfig } from '@google/genai';
import type OpenAI from 'openai';
import type { OpenAIResponseParsingOptions } from '../responseParsingOptions.js';

export type OpenAIRequestContextOverrides = {
splitToolMedia?: boolean;
};

// Extended types to support cache_control for DashScope
export interface ChatCompletionContentPartTextWithCache
extends OpenAI.Chat.ChatCompletionContentPartText {
Expand All @@ -26,6 +30,7 @@ export interface OpenAICompatibleProvider {
): OpenAI.Chat.ChatCompletionCreateParams;
getDefaultGenerationConfig(): GenerateContentConfig;
getResponseParsingOptions?(): OpenAIResponseParsingOptions;
getRequestContextOverrides?(): OpenAIRequestContextOverrides;
}

export type DashScopeRequestMetadata = {
Expand Down
Loading