Skip to content
Open
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
4 changes: 4 additions & 0 deletions apps/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ OPENROUTER_API_KEY=your-openrouter-api-key
OPENROUTER_REFERER=http://localhost:3000
OPENROUTER_TITLE=AI Service

# MiniMax Configuration (OpenAI-compatible)
MINIMAX_API_KEY=your-minimax-api-key
MINIMAX_API_BASE=https://api.minimax.io/v1

# JWT Configuration (ES256 - ECDSA P-256)
# Generate keys with: openssl ecparam -name prime256v1 -genkey -noout -out private.pem
# openssl ec -in private.pem -pubout -out public.pem
Expand Down
2 changes: 2 additions & 0 deletions apps/server/libs/ai-client/src/ai-client.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { OpenAIProvider } from './providers/openai.provider.js';
import { ClaudeProvider } from './providers/claude.provider.js';
import { GeminiProvider } from './providers/gemini.provider.js';
import { OpenRouterProvider } from './providers/openrouter.provider.js';
import { MinimaxProvider } from './providers/minimax.provider.js';

@Global()
@Module({
Expand All @@ -14,6 +15,7 @@ import { OpenRouterProvider } from './providers/openrouter.provider.js';
ClaudeProvider,
GeminiProvider,
OpenRouterProvider,
MinimaxProvider,
AiClientService,
],
exports: [AiClientService],
Expand Down
3 changes: 3 additions & 0 deletions apps/server/libs/ai-client/src/ai-client.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { OpenAIProvider } from './providers/openai.provider.js';
import { ClaudeProvider } from './providers/claude.provider.js';
import { GeminiProvider } from './providers/gemini.provider.js';
import { OpenRouterProvider } from './providers/openrouter.provider.js';
import { MinimaxProvider } from './providers/minimax.provider.js';

@Injectable()
export class AiClientService {
Expand All @@ -20,12 +21,14 @@ export class AiClientService {
private readonly claudeProvider: ClaudeProvider,
private readonly geminiProvider: GeminiProvider,
private readonly openRouterProvider: OpenRouterProvider,
private readonly minimaxProvider: MinimaxProvider,
) {
this.providers = new Map<AIProvider, IAIProviderAdapter>([
[AIProvider.OPENAI, this.openAIProvider],
[AIProvider.CLAUDE, this.claudeProvider],
[AIProvider.GEMINI, this.geminiProvider],
[AIProvider.OPENROUTER, this.openRouterProvider],
[AIProvider.MINIMAX, this.minimaxProvider],
]);
}

Expand Down
17 changes: 15 additions & 2 deletions apps/server/libs/ai-client/src/interfaces/ai-provider.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export enum AIProvider {
CLAUDE = 'claude',
GEMINI = 'gemini',
OPENROUTER = 'openrouter',
MINIMAX = 'minimax',
}

// Claude (Anthropic) Models
Expand Down Expand Up @@ -62,16 +63,26 @@ export type OpenRouterModel =
| (typeof OPENROUTER_MODELS)[number]
| (string & {});

// MiniMax Models
export const MINIMAX_MODELS = ['MiniMax-M3', 'MiniMax-M2.7'] as const;
export type MinimaxModel = (typeof MINIMAX_MODELS)[number] | (string & {});

// Model arrays by provider for runtime validation
export const MODELS_BY_PROVIDER: Record<AIProvider, readonly string[]> = {
[AIProvider.CLAUDE]: CLAUDE_MODELS,
[AIProvider.OPENAI]: OPENAI_MODELS,
[AIProvider.GEMINI]: GEMINI_MODELS,
[AIProvider.OPENROUTER]: OPENROUTER_MODELS,
[AIProvider.MINIMAX]: MINIMAX_MODELS,
};

// Union of all AI models
export type AIModel = ClaudeModel | OpenAIModel | GeminiModel | OpenRouterModel;
export type AIModel =
| ClaudeModel
| OpenAIModel
| GeminiModel
| OpenRouterModel
| MinimaxModel;

// Type mapping: Provider -> Model type
export type ModelForProvider<T extends AIProvider> = T extends AIProvider.CLAUDE
Expand All @@ -82,7 +93,9 @@ export type ModelForProvider<T extends AIProvider> = T extends AIProvider.CLAUDE
? GeminiModel
: T extends AIProvider.OPENROUTER
? OpenRouterModel
: string;
: T extends AIProvider.MINIMAX
? MinimaxModel
: string;

export interface AIMessage {
role: 'system' | 'user' | 'assistant';
Expand Down
109 changes: 109 additions & 0 deletions apps/server/libs/ai-client/src/providers/minimax.provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { Injectable, Logger } from '@nestjs/common';
import OpenAI from 'openai';
import {
IAIProviderAdapter,
AICompletionRequest,
AICompletionResponse,
AIProvider,
AIChatResponse,
} from '../interfaces/ai-provider.interface.js';
import { ConfigService as DbConfigService } from '@team9/database';

@Injectable()
export class MinimaxProvider implements IAIProviderAdapter {
private readonly logger = new Logger(MinimaxProvider.name);
private client: OpenAI | null = null;

constructor(private readonly configService: DbConfigService) {}

private getClient(): OpenAI {
if (!this.client) {
const config = this.configService.getAIProviderConfig('minimax');
const apiKey = config.apiKey;

if (!apiKey) {
this.logger.warn(
'MINIMAX_API_KEY is not configured in database or environment',
);
}

// MiniMax exposes an OpenAI-compatible chat completions API.
this.client = new OpenAI({
apiKey: apiKey || 'dummy-key',
baseURL: config.baseURL || 'https://api.minimax.io/v1',
});
}
return this.client;
}

chat<T extends boolean | undefined = undefined>(
request: AICompletionRequest & { stream?: T },
): AIChatResponse<T> {
if (request.stream) {
return this.streamChat(request) as AIChatResponse<T>;
}
return this.blockingChat(request) as AIChatResponse<T>;
}

private async blockingChat(
request: AICompletionRequest,
): Promise<AICompletionResponse> {
try {
const response = await this.getClient().chat.completions.create({
model: request.model,
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens,
});

const choice = response.choices[0];
return {
provider: AIProvider.MINIMAX,
model: request.model,
content: choice.message.content || '',
usage: {
promptTokens: response.usage?.prompt_tokens || 0,
completionTokens: response.usage?.completion_tokens || 0,
totalTokens: response.usage?.total_tokens || 0,
},
finishReason: choice.finish_reason || undefined,
};
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(
`MiniMax completion error: ${errorMessage}`,
errorStack,
);
throw error;
}
}

private async *streamChat(
request: AICompletionRequest,
): AsyncGenerator<string> {
try {
const stream = await this.getClient().chat.completions.create({
model: request.model,
messages: request.messages,
temperature: request.temperature ?? 0.7,
max_tokens: request.maxTokens,
stream: true,
});

for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
yield content;
}
}
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
const errorStack = error instanceof Error ? error.stack : undefined;
this.logger.error(`MiniMax stream error: ${errorMessage}`, errorStack);
throw error;
}
}
}
13 changes: 13 additions & 0 deletions apps/server/libs/database/src/config-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ export enum ConfigKey {
OPENROUTER_API_KEY = 'OPENROUTER_API_KEY',
OPENROUTER_REFERER = 'OPENROUTER_REFERER',
OPENROUTER_TITLE = 'OPENROUTER_TITLE',

// MiniMax Configuration
MINIMAX_API_KEY = 'MINIMAX_API_KEY',
MINIMAX_API_BASE = 'MINIMAX_API_BASE',
}

/**
Expand Down Expand Up @@ -58,4 +62,13 @@ export const ConfigMetadata: Record<
description: 'OpenRouter X-Title header',
isSecret: false,
},
[ConfigKey.MINIMAX_API_KEY]: {
description: 'MiniMax API key',
isSecret: true,
},
[ConfigKey.MINIMAX_API_BASE]: {
description: 'MiniMax OpenAI-compatible API base URL',
isSecret: false,
defaultValue: 'https://api.minimax.io/v1',
},
};
31 changes: 31 additions & 0 deletions apps/server/libs/database/src/config.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { afterEach, describe, expect, it } from '@jest/globals';
import { ConfigService } from './config.service.js';

describe('ConfigService MiniMax provider', () => {
afterEach(() => {
delete process.env.MINIMAX_API_KEY;
delete process.env.MINIMAX_API_BASE;
});

it('reads the API key and defaults to the global API base URL', () => {
process.env.MINIMAX_API_KEY = 'test-minimax-key';
const service = new ConfigService({} as never);

expect(service.getAIProviderConfig('minimax')).toEqual({
apiKey: 'test-minimax-key',
baseURL: 'https://api.minimax.io/v1',
});
expect(service.isAIProviderConfigured('minimax')).toBe(true);
});

it('honors a custom API base URL from the environment', () => {
process.env.MINIMAX_API_KEY = 'test-minimax-key';
process.env.MINIMAX_API_BASE = 'https://api.minimaxi.com/v1';
const service = new ConfigService({} as never);

expect(service.getAIProviderConfig('minimax')).toEqual({
apiKey: 'test-minimax-key',
baseURL: 'https://api.minimaxi.com/v1',
});
});
});
17 changes: 15 additions & 2 deletions apps/server/libs/database/src/config.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,9 @@ export class ConfigService {
/**
* Get AI provider configuration
*/
getAIProviderConfig(provider: 'openai' | 'claude' | 'gemini' | 'openrouter') {
getAIProviderConfig(
provider: 'openai' | 'claude' | 'gemini' | 'openrouter' | 'minimax',
) {
switch (provider) {
case 'openai':
return {
Expand Down Expand Up @@ -160,14 +162,25 @@ export class ConfigService {
process.env.OPENROUTER_TITLE,
),
};
case 'minimax':
return {
apiKey: this.get(
ConfigKey.MINIMAX_API_KEY,
process.env.MINIMAX_API_KEY,
),
baseURL: this.get(
ConfigKey.MINIMAX_API_BASE,
process.env.MINIMAX_API_BASE || 'https://api.minimax.io/v1',
),
};
}
}

/**
* Check if AI provider is configured
*/
isAIProviderConfigured(
provider: 'openai' | 'claude' | 'gemini' | 'openrouter',
provider: 'openai' | 'claude' | 'gemini' | 'openrouter' | 'minimax',
): boolean {
const config = this.getAIProviderConfig(provider);
return !!config.apiKey;
Expand Down
3 changes: 2 additions & 1 deletion apps/server/libs/shared/src/interfaces/ai.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export interface AIMessage {
* AI Completion Request interface
*/
export interface AICompletionRequest {
provider: 'openai' | 'claude' | 'gemini' | 'openrouter';
provider: 'openai' | 'claude' | 'gemini' | 'openrouter' | 'minimax';
model: string;
messages: AIMessage[];
temperature?: number;
Expand Down Expand Up @@ -41,4 +41,5 @@ export enum AIProvider {
CLAUDE = 'claude',
GEMINI = 'gemini',
OPENROUTER = 'openrouter',
MINIMAX = 'minimax',
}