mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-07 08:26:51 +00:00
Co-authored-by: xiaose <xiaose@minmaxi.com> Co-authored-by: Matt Rubens <mrubens@users.noreply.github.com>
This commit is contained in:
parent
f839d4c27b
commit
414345ce7d
31 changed files with 568 additions and 0 deletions
|
|
@ -218,6 +218,7 @@ export const SECRET_STATE_KEYS = [
|
|||
"doubaoApiKey",
|
||||
"moonshotApiKey",
|
||||
"mistralApiKey",
|
||||
"minimaxApiKey",
|
||||
"unboundApiKey",
|
||||
"requestyApiKey",
|
||||
"xaiApiKey",
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import {
|
|||
vscodeLlmModels,
|
||||
xaiModels,
|
||||
internationalZAiModels,
|
||||
minimaxModels,
|
||||
} from "./providers/index.js"
|
||||
|
||||
/**
|
||||
|
|
@ -131,6 +132,7 @@ export const providerNames = [
|
|||
"groq",
|
||||
"mistral",
|
||||
"moonshot",
|
||||
"minimax",
|
||||
"openai-native",
|
||||
"qwen-code",
|
||||
"roo",
|
||||
|
|
@ -327,6 +329,13 @@ const moonshotSchema = apiModelIdProviderModelSchema.extend({
|
|||
moonshotApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const minimaxSchema = apiModelIdProviderModelSchema.extend({
|
||||
minimaxBaseUrl: z
|
||||
.union([z.literal("https://api.minimax.io/v1"), z.literal("https://api.minimaxi.com/v1")])
|
||||
.optional(),
|
||||
minimaxApiKey: z.string().optional(),
|
||||
})
|
||||
|
||||
const unboundSchema = baseProviderSettingsSchema.extend({
|
||||
unboundApiKey: z.string().optional(),
|
||||
unboundModelId: z.string().optional(),
|
||||
|
|
@ -435,6 +444,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
|
|||
deepInfraSchema.merge(z.object({ apiProvider: z.literal("deepinfra") })),
|
||||
doubaoSchema.merge(z.object({ apiProvider: z.literal("doubao") })),
|
||||
moonshotSchema.merge(z.object({ apiProvider: z.literal("moonshot") })),
|
||||
minimaxSchema.merge(z.object({ apiProvider: z.literal("minimax") })),
|
||||
unboundSchema.merge(z.object({ apiProvider: z.literal("unbound") })),
|
||||
requestySchema.merge(z.object({ apiProvider: z.literal("requesty") })),
|
||||
humanRelaySchema.merge(z.object({ apiProvider: z.literal("human-relay") })),
|
||||
|
|
@ -476,6 +486,7 @@ export const providerSettingsSchema = z.object({
|
|||
...deepInfraSchema.shape,
|
||||
...doubaoSchema.shape,
|
||||
...moonshotSchema.shape,
|
||||
...minimaxSchema.shape,
|
||||
...unboundSchema.shape,
|
||||
...requestySchema.shape,
|
||||
...humanRelaySchema.shape,
|
||||
|
|
@ -560,6 +571,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
|
|||
"gemini-cli": "apiModelId",
|
||||
mistral: "apiModelId",
|
||||
moonshot: "apiModelId",
|
||||
minimax: "apiModelId",
|
||||
deepseek: "apiModelId",
|
||||
deepinfra: "deepInfraModelId",
|
||||
doubao: "apiModelId",
|
||||
|
|
@ -676,6 +688,11 @@ export const MODELS_BY_PROVIDER: Record<
|
|||
label: "Moonshot",
|
||||
models: Object.keys(moonshotModels),
|
||||
},
|
||||
minimax: {
|
||||
id: "minimax",
|
||||
label: "MiniMax",
|
||||
models: Object.keys(minimaxModels),
|
||||
},
|
||||
"openai-native": {
|
||||
id: "openai-native",
|
||||
label: "OpenAI",
|
||||
|
|
|
|||
|
|
@ -30,3 +30,4 @@ export * from "./xai.js"
|
|||
export * from "./vercel-ai-gateway.js"
|
||||
export * from "./zai.js"
|
||||
export * from "./deepinfra.js"
|
||||
export * from "./minimax.js"
|
||||
|
|
|
|||
24
packages/types/src/providers/minimax.ts
Normal file
24
packages/types/src/providers/minimax.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { ModelInfo } from "../model.js"
|
||||
|
||||
// Minimax
|
||||
// https://www.minimax.io/platform/document/text_api_intro
|
||||
// https://www.minimax.io/platform/document/pricing
|
||||
export type MinimaxModelId = keyof typeof minimaxModels
|
||||
export const minimaxDefaultModelId: MinimaxModelId = "MiniMax-M2"
|
||||
|
||||
export const minimaxModels = {
|
||||
"MiniMax-M2": {
|
||||
maxTokens: 16_384,
|
||||
contextWindow: 192_000,
|
||||
supportsImages: false,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0.3,
|
||||
outputPrice: 1.2,
|
||||
cacheWritesPrice: 0,
|
||||
cacheReadsPrice: 0,
|
||||
description:
|
||||
"MiniMax M2, a model born for Agents and code, featuring Top-tier Coding Capabilities, Powerful Agentic Performance, and Ultimate Cost-Effectiveness & Speed.",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
||||
export const MINIMAX_DEFAULT_TEMPERATURE = 1.0
|
||||
|
|
@ -40,6 +40,7 @@ import {
|
|||
FeatherlessHandler,
|
||||
VercelAiGatewayHandler,
|
||||
DeepInfraHandler,
|
||||
MiniMaxHandler,
|
||||
} from "./providers"
|
||||
import { NativeOllamaHandler } from "./providers/native-ollama"
|
||||
|
||||
|
|
@ -165,6 +166,8 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler {
|
|||
return new FeatherlessHandler(options)
|
||||
case "vercel-ai-gateway":
|
||||
return new VercelAiGatewayHandler(options)
|
||||
case "minimax":
|
||||
return new MiniMaxHandler(options)
|
||||
default:
|
||||
apiProvider satisfies "gemini-cli" | undefined
|
||||
return new AnthropicHandler(options)
|
||||
|
|
|
|||
314
src/api/providers/__tests__/minimax.spec.ts
Normal file
314
src/api/providers/__tests__/minimax.spec.ts
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
// npx vitest run src/api/providers/__tests__/minimax.spec.ts
|
||||
|
||||
vitest.mock("vscode", () => ({
|
||||
workspace: {
|
||||
getConfiguration: vitest.fn().mockReturnValue({
|
||||
get: vitest.fn().mockReturnValue(600), // Default timeout in seconds
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import OpenAI from "openai"
|
||||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
|
||||
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
|
||||
|
||||
import { MiniMaxHandler } from "../minimax"
|
||||
|
||||
vitest.mock("openai", () => {
|
||||
const createMock = vitest.fn()
|
||||
return {
|
||||
default: vitest.fn(() => ({ chat: { completions: { create: createMock } } })),
|
||||
}
|
||||
})
|
||||
|
||||
describe("MiniMaxHandler", () => {
|
||||
let handler: MiniMaxHandler
|
||||
let mockCreate: any
|
||||
|
||||
beforeEach(() => {
|
||||
vitest.clearAllMocks()
|
||||
mockCreate = (OpenAI as unknown as any)().chat.completions.create
|
||||
})
|
||||
|
||||
describe("International MiniMax (default)", () => {
|
||||
beforeEach(() => {
|
||||
handler = new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimax.io/v1",
|
||||
})
|
||||
})
|
||||
|
||||
it("should use the correct international MiniMax base URL by default", () => {
|
||||
new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/v1",
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it("should use the provided API key", () => {
|
||||
const minimaxApiKey = "test-minimax-api-key"
|
||||
new MiniMaxHandler({ minimaxApiKey })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(minimaxDefaultModelId)
|
||||
expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId])
|
||||
})
|
||||
|
||||
it("should return specified model when valid model is provided", () => {
|
||||
const testModelId: MinimaxModelId = "MiniMax-M2"
|
||||
const handlerWithModel = new MiniMaxHandler({
|
||||
apiModelId: testModelId,
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(minimaxModels[testModelId])
|
||||
})
|
||||
|
||||
it("should return MiniMax-M2 model with correct configuration", () => {
|
||||
const testModelId: MinimaxModelId = "MiniMax-M2"
|
||||
const handlerWithModel = new MiniMaxHandler({
|
||||
apiModelId: testModelId,
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
})
|
||||
const model = handlerWithModel.getModel()
|
||||
expect(model.id).toBe(testModelId)
|
||||
expect(model.info).toEqual(minimaxModels[testModelId])
|
||||
expect(model.info.contextWindow).toBe(192_000)
|
||||
expect(model.info.maxTokens).toBe(16_384)
|
||||
expect(model.info.supportsPromptCache).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("China MiniMax", () => {
|
||||
beforeEach(() => {
|
||||
handler = new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimaxi.com/v1",
|
||||
})
|
||||
})
|
||||
|
||||
it("should use the correct China MiniMax base URL", () => {
|
||||
new MiniMaxHandler({
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
minimaxBaseUrl: "https://api.minimaxi.com/v1",
|
||||
})
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ baseURL: "https://api.minimaxi.com/v1" }))
|
||||
})
|
||||
|
||||
it("should use the provided API key for China", () => {
|
||||
const minimaxApiKey = "test-minimax-api-key"
|
||||
new MiniMaxHandler({ minimaxApiKey, minimaxBaseUrl: "https://api.minimaxi.com/v1" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(expect.objectContaining({ apiKey: minimaxApiKey }))
|
||||
})
|
||||
|
||||
it("should return default model when no model is specified", () => {
|
||||
const model = handler.getModel()
|
||||
expect(model.id).toBe(minimaxDefaultModelId)
|
||||
expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId])
|
||||
})
|
||||
})
|
||||
|
||||
describe("Default behavior", () => {
|
||||
it("should default to international base URL when none is specified", () => {
|
||||
const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
expect(OpenAI).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
baseURL: "https://api.minimax.io/v1",
|
||||
}),
|
||||
)
|
||||
|
||||
const model = handlerDefault.getModel()
|
||||
expect(model.id).toBe(minimaxDefaultModelId)
|
||||
expect(model.info).toEqual(minimaxModels[minimaxDefaultModelId])
|
||||
})
|
||||
|
||||
it("should default to MiniMax-M2 model", () => {
|
||||
const handlerDefault = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
const model = handlerDefault.getModel()
|
||||
expect(model.id).toBe("MiniMax-M2")
|
||||
})
|
||||
})
|
||||
|
||||
describe("API Methods", () => {
|
||||
beforeEach(() => {
|
||||
handler = new MiniMaxHandler({ minimaxApiKey: "test-minimax-api-key" })
|
||||
})
|
||||
|
||||
it("completePrompt method should return text from MiniMax API", async () => {
|
||||
const expectedResponse = "This is a test response from MiniMax"
|
||||
mockCreate.mockResolvedValueOnce({ choices: [{ message: { content: expectedResponse } }] })
|
||||
const result = await handler.completePrompt("test prompt")
|
||||
expect(result).toBe(expectedResponse)
|
||||
})
|
||||
|
||||
it("should handle errors in completePrompt", async () => {
|
||||
const errorMessage = "MiniMax API error"
|
||||
mockCreate.mockRejectedValueOnce(new Error(errorMessage))
|
||||
await expect(handler.completePrompt("test prompt")).rejects.toThrow()
|
||||
})
|
||||
|
||||
it("createMessage should yield text content from stream", async () => {
|
||||
const testContent = "This is test content from MiniMax stream"
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: testContent } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "text", text: testContent })
|
||||
})
|
||||
|
||||
it("should handle reasoning tags (<think>) from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: "<think>Let me think" } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: " about this</think>" } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: { choices: [{ delta: { content: "The answer is 42" } }] },
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// XmlMatcher yields chunks as they're processed
|
||||
expect(chunks).toEqual([
|
||||
{ type: "reasoning", text: "Let me think" },
|
||||
{ type: "reasoning", text: " about this" },
|
||||
{ type: "text", text: "The answer is 42" },
|
||||
])
|
||||
})
|
||||
|
||||
it("createMessage should yield usage data from stream", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
next: vitest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: {
|
||||
choices: [{ delta: {} }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20 },
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const stream = handler.createMessage("system prompt", [])
|
||||
const firstChunk = await stream.next()
|
||||
|
||||
expect(firstChunk.done).toBe(false)
|
||||
expect(firstChunk.value).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20 })
|
||||
})
|
||||
|
||||
it("createMessage should pass correct parameters to MiniMax client", async () => {
|
||||
const modelId: MinimaxModelId = "MiniMax-M2"
|
||||
const modelInfo = minimaxModels[modelId]
|
||||
const handlerWithModel = new MiniMaxHandler({
|
||||
apiModelId: modelId,
|
||||
minimaxApiKey: "test-minimax-api-key",
|
||||
})
|
||||
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const systemPrompt = "Test system prompt for MiniMax"
|
||||
const messages: Anthropic.Messages.MessageParam[] = [{ role: "user", content: "Test message for MiniMax" }]
|
||||
|
||||
const messageGenerator = handlerWithModel.createMessage(systemPrompt, messages)
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: modelId,
|
||||
max_tokens: Math.min(modelInfo.maxTokens, Math.ceil(modelInfo.contextWindow * 0.2)),
|
||||
temperature: 1,
|
||||
messages: expect.arrayContaining([{ role: "system", content: systemPrompt }]),
|
||||
stream: true,
|
||||
stream_options: { include_usage: true },
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
|
||||
it("should use temperature 1 by default", async () => {
|
||||
mockCreate.mockImplementationOnce(() => {
|
||||
return {
|
||||
[Symbol.asyncIterator]: () => ({
|
||||
async next() {
|
||||
return { done: true }
|
||||
},
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const messageGenerator = handler.createMessage("test", [])
|
||||
await messageGenerator.next()
|
||||
|
||||
expect(mockCreate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
temperature: 1,
|
||||
}),
|
||||
undefined,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("Model Configuration", () => {
|
||||
it("should correctly configure MiniMax-M2 model properties", () => {
|
||||
const model = minimaxModels["MiniMax-M2"]
|
||||
expect(model.maxTokens).toBe(16_384)
|
||||
expect(model.contextWindow).toBe(192_000)
|
||||
expect(model.supportsImages).toBe(false)
|
||||
expect(model.supportsPromptCache).toBe(false)
|
||||
expect(model.inputPrice).toBe(0.3)
|
||||
expect(model.outputPrice).toBe(1.2)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -34,3 +34,4 @@ export { RooHandler } from "./roo"
|
|||
export { FeatherlessHandler } from "./featherless"
|
||||
export { VercelAiGatewayHandler } from "./vercel-ai-gateway"
|
||||
export { DeepInfraHandler } from "./deepinfra"
|
||||
export { MiniMaxHandler } from "./minimax"
|
||||
|
|
|
|||
62
src/api/providers/minimax.ts
Normal file
62
src/api/providers/minimax.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { Anthropic } from "@anthropic-ai/sdk"
|
||||
import { type MinimaxModelId, minimaxDefaultModelId, minimaxModels } from "@roo-code/types"
|
||||
|
||||
import type { ApiHandlerOptions } from "../../shared/api"
|
||||
import { XmlMatcher } from "../../utils/xml-matcher"
|
||||
import { ApiStream } from "../transform/stream"
|
||||
import type { ApiHandlerCreateMessageMetadata } from "../index"
|
||||
|
||||
import { BaseOpenAiCompatibleProvider } from "./base-openai-compatible-provider"
|
||||
|
||||
export class MiniMaxHandler extends BaseOpenAiCompatibleProvider<MinimaxModelId> {
|
||||
constructor(options: ApiHandlerOptions) {
|
||||
super({
|
||||
...options,
|
||||
providerName: "MiniMax",
|
||||
baseURL: options.minimaxBaseUrl ?? "https://api.minimax.io/v1",
|
||||
apiKey: options.minimaxApiKey,
|
||||
defaultProviderModelId: minimaxDefaultModelId,
|
||||
providerModels: minimaxModels,
|
||||
defaultTemperature: 1.0,
|
||||
})
|
||||
}
|
||||
|
||||
override async *createMessage(
|
||||
systemPrompt: string,
|
||||
messages: Anthropic.Messages.MessageParam[],
|
||||
metadata?: ApiHandlerCreateMessageMetadata,
|
||||
): ApiStream {
|
||||
const stream = await this.createStream(systemPrompt, messages, metadata)
|
||||
|
||||
const matcher = new XmlMatcher(
|
||||
"think",
|
||||
(chunk) =>
|
||||
({
|
||||
type: chunk.matched ? "reasoning" : "text",
|
||||
text: chunk.data,
|
||||
}) as const,
|
||||
)
|
||||
|
||||
for await (const chunk of stream) {
|
||||
const delta = chunk.choices[0]?.delta
|
||||
|
||||
if (delta?.content) {
|
||||
for (const matcherChunk of matcher.update(delta.content)) {
|
||||
yield matcherChunk
|
||||
}
|
||||
}
|
||||
|
||||
if (chunk.usage) {
|
||||
yield {
|
||||
type: "usage",
|
||||
inputTokens: chunk.usage.prompt_tokens || 0,
|
||||
outputTokens: chunk.usage.completion_tokens || 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of matcher.final()) {
|
||||
yield chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -37,6 +37,7 @@ import {
|
|||
rooDefaultModelId,
|
||||
vercelAiGatewayDefaultModelId,
|
||||
deepInfraDefaultModelId,
|
||||
minimaxDefaultModelId,
|
||||
} from "@roo-code/types"
|
||||
|
||||
import { vscode } from "@src/utils/vscode"
|
||||
|
|
@ -96,6 +97,7 @@ import {
|
|||
Featherless,
|
||||
VercelAiGateway,
|
||||
DeepInfra,
|
||||
MiniMax,
|
||||
} from "./providers"
|
||||
|
||||
import { MODELS_BY_PROVIDER, PROVIDERS } from "./constants"
|
||||
|
|
@ -341,6 +343,7 @@ const ApiOptions = ({
|
|||
deepseek: { field: "apiModelId", default: deepSeekDefaultModelId },
|
||||
doubao: { field: "apiModelId", default: doubaoDefaultModelId },
|
||||
moonshot: { field: "apiModelId", default: moonshotDefaultModelId },
|
||||
minimax: { field: "apiModelId", default: minimaxDefaultModelId },
|
||||
mistral: { field: "apiModelId", default: mistralDefaultModelId },
|
||||
xai: { field: "apiModelId", default: xaiDefaultModelId },
|
||||
groq: { field: "apiModelId", default: groqDefaultModelId },
|
||||
|
|
@ -593,6 +596,10 @@ const ApiOptions = ({
|
|||
<Moonshot apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
)}
|
||||
|
||||
{selectedProvider === "minimax" && (
|
||||
<MiniMax apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
)}
|
||||
|
||||
{selectedProvider === "vscode-lm" && (
|
||||
<VSCodeLM apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} />
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
internationalZAiModels,
|
||||
fireworksModels,
|
||||
featherlessModels,
|
||||
minimaxModels,
|
||||
} from "@roo-code/types"
|
||||
|
||||
export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, ModelInfo>>> = {
|
||||
|
|
@ -42,6 +43,7 @@ export const MODELS_BY_PROVIDER: Partial<Record<ProviderName, Record<string, Mod
|
|||
zai: internationalZAiModels,
|
||||
fireworks: fireworksModels,
|
||||
featherless: featherlessModels,
|
||||
minimax: minimaxModels,
|
||||
}
|
||||
|
||||
export const PROVIDERS = [
|
||||
|
|
@ -79,4 +81,5 @@ export const PROVIDERS = [
|
|||
{ value: "io-intelligence", label: "IO Intelligence" },
|
||||
{ value: "roo", label: "Roo Code Cloud" },
|
||||
{ value: "vercel-ai-gateway", label: "Vercel AI Gateway" },
|
||||
{ value: "minimax", label: "MiniMax" },
|
||||
].sort((a, b) => a.label.localeCompare(b.label))
|
||||
|
|
|
|||
73
webview-ui/src/components/settings/providers/MiniMax.tsx
Normal file
73
webview-ui/src/components/settings/providers/MiniMax.tsx
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import { useCallback } from "react"
|
||||
import { VSCodeTextField, VSCodeDropdown, VSCodeOption } from "@vscode/webview-ui-toolkit/react"
|
||||
|
||||
import type { ProviderSettings } from "@roo-code/types"
|
||||
|
||||
import { useAppTranslation } from "@src/i18n/TranslationContext"
|
||||
import { VSCodeButtonLink } from "@src/components/common/VSCodeButtonLink"
|
||||
|
||||
import { inputEventTransform } from "../transforms"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type MiniMaxProps = {
|
||||
apiConfiguration: ProviderSettings
|
||||
setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void
|
||||
}
|
||||
|
||||
export const MiniMax = ({ apiConfiguration, setApiConfigurationField }: MiniMaxProps) => {
|
||||
const { t } = useAppTranslation()
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
<K extends keyof ProviderSettings, E>(
|
||||
field: K,
|
||||
transform: (event: E) => ProviderSettings[K] = inputEventTransform,
|
||||
) =>
|
||||
(event: E | Event) => {
|
||||
setApiConfigurationField(field, transform(event as E))
|
||||
},
|
||||
[setApiConfigurationField],
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<label className="block font-medium mb-1">{t("settings:providers.minimaxBaseUrl")}</label>
|
||||
<VSCodeDropdown
|
||||
value={apiConfiguration.minimaxBaseUrl}
|
||||
onChange={handleInputChange("minimaxBaseUrl")}
|
||||
className={cn("w-full")}>
|
||||
<VSCodeOption value="https://api.minimax.io/v1" className="p-2">
|
||||
api.minimax.io
|
||||
</VSCodeOption>
|
||||
<VSCodeOption value="https://api.minimaxi.com/v1" className="p-2">
|
||||
api.minimaxi.com
|
||||
</VSCodeOption>
|
||||
</VSCodeDropdown>
|
||||
</div>
|
||||
<div>
|
||||
<VSCodeTextField
|
||||
value={apiConfiguration?.minimaxApiKey || ""}
|
||||
type="password"
|
||||
onInput={handleInputChange("minimaxApiKey")}
|
||||
placeholder={t("settings:placeholders.apiKey")}
|
||||
className="w-full">
|
||||
<label className="block font-medium mb-1">{t("settings:providers.minimaxApiKey")}</label>
|
||||
</VSCodeTextField>
|
||||
<div className="text-sm text-vscode-descriptionForeground">
|
||||
{t("settings:providers.apiKeyStorageNotice")}
|
||||
</div>
|
||||
{!apiConfiguration?.minimaxApiKey && (
|
||||
<VSCodeButtonLink
|
||||
href={
|
||||
apiConfiguration.minimaxBaseUrl === "https://api.minimaxi.com/v1"
|
||||
? "https://platform.minimaxi.com/user-center/basic-information/interface-key"
|
||||
: "https://www.minimax.io/platform/user-center/basic-information/interface-key"
|
||||
}
|
||||
appearance="secondary">
|
||||
{t("settings:providers.getMiniMaxApiKey")}
|
||||
</VSCodeButtonLink>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -31,3 +31,4 @@ export { Fireworks } from "./Fireworks"
|
|||
export { Featherless } from "./Featherless"
|
||||
export { VercelAiGateway } from "./VercelAiGateway"
|
||||
export { DeepInfra } from "./DeepInfra"
|
||||
export { MiniMax } from "./MiniMax"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,8 @@ import {
|
|||
deepSeekModels,
|
||||
moonshotDefaultModelId,
|
||||
moonshotModels,
|
||||
minimaxDefaultModelId,
|
||||
minimaxModels,
|
||||
geminiDefaultModelId,
|
||||
geminiModels,
|
||||
mistralDefaultModelId,
|
||||
|
|
@ -236,6 +238,11 @@ function getSelectedModel({
|
|||
const info = moonshotModels[id as keyof typeof moonshotModels]
|
||||
return { id, info }
|
||||
}
|
||||
case "minimax": {
|
||||
const id = apiConfiguration.apiModelId ?? minimaxDefaultModelId
|
||||
const info = minimaxModels[id as keyof typeof minimaxModels]
|
||||
return { id, info }
|
||||
}
|
||||
case "zai": {
|
||||
const isChina = apiConfiguration.zaiApiLine === "china_coding"
|
||||
const models = isChina ? mainlandZAiModels : internationalZAiModels
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ca/settings.json
generated
3
webview-ui/src/i18n/locales/ca/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Obtenir clau API de Z AI",
|
||||
"zaiEntrypoint": "Punt d'entrada de Z AI",
|
||||
"zaiEntrypointDescription": "Si us plau, seleccioneu el punt d'entrada de l'API apropiat segons la vostra ubicació. Si sou a la Xina, trieu open.bigmodel.cn. Altrament, trieu api.z.ai.",
|
||||
"minimaxApiKey": "Clau API de MiniMax",
|
||||
"getMiniMaxApiKey": "Obtenir clau API de MiniMax",
|
||||
"minimaxBaseUrl": "Punt d'entrada de MiniMax",
|
||||
"geminiApiKey": "Clau API de Gemini",
|
||||
"getGroqApiKey": "Obtenir clau API de Groq",
|
||||
"groqApiKey": "Clau API de Groq",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/de/settings.json
generated
3
webview-ui/src/i18n/locales/de/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Z AI API-Schlüssel erhalten",
|
||||
"zaiEntrypoint": "Z AI Einstiegspunkt",
|
||||
"zaiEntrypointDescription": "Bitte wählen Sie den entsprechenden API-Einstiegspunkt basierend auf Ihrem Standort. Wenn Sie sich in China befinden, wählen Sie open.bigmodel.cn. Andernfalls wählen Sie api.z.ai.",
|
||||
"minimaxApiKey": "MiniMax API-Schlüssel",
|
||||
"getMiniMaxApiKey": "MiniMax API-Schlüssel erhalten",
|
||||
"minimaxBaseUrl": "MiniMax-Einstiegspunkt",
|
||||
"geminiApiKey": "Gemini API-Schlüssel",
|
||||
"getGroqApiKey": "Groq API-Schlüssel erhalten",
|
||||
"groqApiKey": "Groq API-Schlüssel",
|
||||
|
|
|
|||
|
|
@ -298,6 +298,9 @@
|
|||
"moonshotApiKey": "Moonshot API Key",
|
||||
"getMoonshotApiKey": "Get Moonshot API Key",
|
||||
"moonshotBaseUrl": "Moonshot Entrypoint",
|
||||
"minimaxApiKey": "MiniMax API Key",
|
||||
"getMiniMaxApiKey": "Get MiniMax API Key",
|
||||
"minimaxBaseUrl": "MiniMax Entrypoint",
|
||||
"zaiApiKey": "Z AI API Key",
|
||||
"getZaiApiKey": "Get Z AI API Key",
|
||||
"zaiEntrypoint": "Z AI Entrypoint",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/es/settings.json
generated
3
webview-ui/src/i18n/locales/es/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Obtener clave API de Z AI",
|
||||
"zaiEntrypoint": "Punto de entrada de Z AI",
|
||||
"zaiEntrypointDescription": "Por favor, seleccione el punto de entrada de API apropiado según su ubicación. Si está en China, elija open.bigmodel.cn. De lo contrario, elija api.z.ai.",
|
||||
"minimaxApiKey": "Clave API de MiniMax",
|
||||
"getMiniMaxApiKey": "Obtener clave API de MiniMax",
|
||||
"minimaxBaseUrl": "Punto de entrada de MiniMax",
|
||||
"geminiApiKey": "Clave API de Gemini",
|
||||
"getGroqApiKey": "Obtener clave API de Groq",
|
||||
"groqApiKey": "Clave API de Groq",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/fr/settings.json
generated
3
webview-ui/src/i18n/locales/fr/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Obtenir la clé API Z AI",
|
||||
"zaiEntrypoint": "Point d'entrée Z AI",
|
||||
"zaiEntrypointDescription": "Veuillez sélectionner le point d'entrée API approprié en fonction de votre emplacement. Si vous êtes en Chine, choisissez open.bigmodel.cn. Sinon, choisissez api.z.ai.",
|
||||
"minimaxApiKey": "Clé API MiniMax",
|
||||
"getMiniMaxApiKey": "Obtenir la clé API MiniMax",
|
||||
"minimaxBaseUrl": "Point d'entrée MiniMax",
|
||||
"geminiApiKey": "Clé API Gemini",
|
||||
"getGroqApiKey": "Obtenir la clé API Groq",
|
||||
"groqApiKey": "Clé API Groq",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/hi/settings.json
generated
3
webview-ui/src/i18n/locales/hi/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Z AI API कुंजी प्राप्त करें",
|
||||
"zaiEntrypoint": "Z AI प्रवेश बिंदु",
|
||||
"zaiEntrypointDescription": "कृपया अपने स्थान के आधार पर उपयुक्त API प्रवेश बिंदु का चयन करें। यदि आप चीन में हैं, तो open.bigmodel.cn चुनें। अन्यथा, api.z.ai चुनें।",
|
||||
"minimaxApiKey": "MiniMax API कुंजी",
|
||||
"getMiniMaxApiKey": "MiniMax API कुंजी प्राप्त करें",
|
||||
"minimaxBaseUrl": "MiniMax प्रवेश बिंदु",
|
||||
"geminiApiKey": "Gemini API कुंजी",
|
||||
"getGroqApiKey": "Groq API कुंजी प्राप्त करें",
|
||||
"groqApiKey": "Groq API कुंजी",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/id/settings.json
generated
3
webview-ui/src/i18n/locales/id/settings.json
generated
|
|
@ -301,6 +301,9 @@
|
|||
"getZaiApiKey": "Dapatkan Kunci API Z AI",
|
||||
"zaiEntrypoint": "Titik Masuk Z AI",
|
||||
"zaiEntrypointDescription": "Silakan pilih titik masuk API yang sesuai berdasarkan lokasi Anda. Jika Anda berada di China, pilih open.bigmodel.cn. Jika tidak, pilih api.z.ai.",
|
||||
"minimaxApiKey": "Kunci API MiniMax",
|
||||
"getMiniMaxApiKey": "Dapatkan Kunci API MiniMax",
|
||||
"minimaxBaseUrl": "Titik Masuk MiniMax",
|
||||
"geminiApiKey": "Gemini API Key",
|
||||
"getGroqApiKey": "Dapatkan Groq API Key",
|
||||
"groqApiKey": "Groq API Key",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/it/settings.json
generated
3
webview-ui/src/i18n/locales/it/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Ottieni chiave API Z AI",
|
||||
"zaiEntrypoint": "Punto di ingresso Z AI",
|
||||
"zaiEntrypointDescription": "Si prega di selezionare il punto di ingresso API appropriato in base alla propria posizione. Se ti trovi in Cina, scegli open.bigmodel.cn. Altrimenti, scegli api.z.ai.",
|
||||
"minimaxApiKey": "Chiave API MiniMax",
|
||||
"getMiniMaxApiKey": "Ottieni chiave API MiniMax",
|
||||
"minimaxBaseUrl": "Punto di ingresso MiniMax",
|
||||
"geminiApiKey": "Chiave API Gemini",
|
||||
"getGroqApiKey": "Ottieni chiave API Groq",
|
||||
"groqApiKey": "Chiave API Groq",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ja/settings.json
generated
3
webview-ui/src/i18n/locales/ja/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Z AI APIキーを取得",
|
||||
"zaiEntrypoint": "Z AI エントリーポイント",
|
||||
"zaiEntrypointDescription": "お住まいの地域に応じて適切な API エントリーポイントを選択してください。中国にお住まいの場合は open.bigmodel.cn を選択してください。それ以外の場合は api.z.ai を選択してください。",
|
||||
"minimaxApiKey": "MiniMax APIキー",
|
||||
"getMiniMaxApiKey": "MiniMax APIキーを取得",
|
||||
"minimaxBaseUrl": "MiniMax エントリーポイント",
|
||||
"geminiApiKey": "Gemini APIキー",
|
||||
"getGroqApiKey": "Groq APIキーを取得",
|
||||
"groqApiKey": "Groq APIキー",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ko/settings.json
generated
3
webview-ui/src/i18n/locales/ko/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Z AI API 키 받기",
|
||||
"zaiEntrypoint": "Z AI 엔트리포인트",
|
||||
"zaiEntrypointDescription": "위치에 따라 적절한 API 엔트리포인트를 선택하세요. 중국에 있다면 open.bigmodel.cn을 선택하세요. 그렇지 않으면 api.z.ai를 선택하세요.",
|
||||
"minimaxApiKey": "MiniMax API 키",
|
||||
"getMiniMaxApiKey": "MiniMax API 키 받기",
|
||||
"minimaxBaseUrl": "MiniMax 엔트리포인트",
|
||||
"geminiApiKey": "Gemini API 키",
|
||||
"getGroqApiKey": "Groq API 키 받기",
|
||||
"groqApiKey": "Groq API 키",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/nl/settings.json
generated
3
webview-ui/src/i18n/locales/nl/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Z AI API-sleutel ophalen",
|
||||
"zaiEntrypoint": "Z AI-ingangspunt",
|
||||
"zaiEntrypointDescription": "Selecteer het juiste API-ingangspunt op basis van uw locatie. Als u zich in China bevindt, kies dan open.bigmodel.cn. Anders kiest u api.z.ai.",
|
||||
"minimaxApiKey": "MiniMax API-sleutel",
|
||||
"getMiniMaxApiKey": "MiniMax API-sleutel ophalen",
|
||||
"minimaxBaseUrl": "MiniMax-ingangspunt",
|
||||
"geminiApiKey": "Gemini API-sleutel",
|
||||
"getGroqApiKey": "Groq API-sleutel ophalen",
|
||||
"groqApiKey": "Groq API-sleutel",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/pl/settings.json
generated
3
webview-ui/src/i18n/locales/pl/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Uzyskaj klucz API Z AI",
|
||||
"zaiEntrypoint": "Punkt wejścia Z AI",
|
||||
"zaiEntrypointDescription": "Wybierz odpowiedni punkt wejścia API w zależności od swojej lokalizacji. Jeśli jesteś w Chinach, wybierz open.bigmodel.cn. W przeciwnym razie wybierz api.z.ai.",
|
||||
"minimaxApiKey": "Klucz API MiniMax",
|
||||
"getMiniMaxApiKey": "Uzyskaj klucz API MiniMax",
|
||||
"minimaxBaseUrl": "Punkt wejścia MiniMax",
|
||||
"geminiApiKey": "Klucz API Gemini",
|
||||
"getGroqApiKey": "Uzyskaj klucz API Groq",
|
||||
"groqApiKey": "Klucz API Groq",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/pt-BR/settings.json
generated
3
webview-ui/src/i18n/locales/pt-BR/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Obter chave de API Z AI",
|
||||
"zaiEntrypoint": "Ponto de entrada Z AI",
|
||||
"zaiEntrypointDescription": "Selecione o ponto de entrada da API apropriado com base na sua localização. Se você estiver na China, escolha open.bigmodel.cn. Caso contrário, escolha api.z.ai.",
|
||||
"minimaxApiKey": "Chave de API MiniMax",
|
||||
"getMiniMaxApiKey": "Obter chave de API MiniMax",
|
||||
"minimaxBaseUrl": "Ponto de entrada MiniMax",
|
||||
"geminiApiKey": "Chave de API Gemini",
|
||||
"getGroqApiKey": "Obter chave de API Groq",
|
||||
"groqApiKey": "Chave de API Groq",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/ru/settings.json
generated
3
webview-ui/src/i18n/locales/ru/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Получить Z AI API-ключ",
|
||||
"zaiEntrypoint": "Точка входа Z AI",
|
||||
"zaiEntrypointDescription": "Пожалуйста, выберите подходящую точку входа API в зависимости от вашего местоположения. Если вы находитесь в Китае, выберите open.bigmodel.cn. В противном случае выберите api.z.ai.",
|
||||
"minimaxApiKey": "MiniMax API-ключ",
|
||||
"getMiniMaxApiKey": "Получить MiniMax API-ключ",
|
||||
"minimaxBaseUrl": "Точка входа MiniMax",
|
||||
"geminiApiKey": "Gemini API-ключ",
|
||||
"getGroqApiKey": "Получить Groq API-ключ",
|
||||
"groqApiKey": "Groq API-ключ",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/tr/settings.json
generated
3
webview-ui/src/i18n/locales/tr/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Z AI API Anahtarı Al",
|
||||
"zaiEntrypoint": "Z AI Giriş Noktası",
|
||||
"zaiEntrypointDescription": "Konumunuza göre uygun API giriş noktasını seçin. Çin'de iseniz open.bigmodel.cn'yi seçin. Aksi takdirde api.z.ai'yi seçin.",
|
||||
"minimaxApiKey": "MiniMax API Anahtarı",
|
||||
"getMiniMaxApiKey": "MiniMax API Anahtarı Al",
|
||||
"minimaxBaseUrl": "MiniMax Giriş Noktası",
|
||||
"geminiApiKey": "Gemini API Anahtarı",
|
||||
"getGroqApiKey": "Groq API Anahtarı Al",
|
||||
"groqApiKey": "Groq API Anahtarı",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/vi/settings.json
generated
3
webview-ui/src/i18n/locales/vi/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "Lấy khóa API Z AI",
|
||||
"zaiEntrypoint": "Điểm vào Z AI",
|
||||
"zaiEntrypointDescription": "Vui lòng chọn điểm vào API phù hợp dựa trên vị trí của bạn. Nếu bạn ở Trung Quốc, hãy chọn open.bigmodel.cn. Ngược lại, hãy chọn api.z.ai.",
|
||||
"minimaxApiKey": "Khóa API MiniMax",
|
||||
"getMiniMaxApiKey": "Lấy khóa API MiniMax",
|
||||
"minimaxBaseUrl": "Điểm vào MiniMax",
|
||||
"geminiApiKey": "Khóa API Gemini",
|
||||
"getGroqApiKey": "Lấy khóa API Groq",
|
||||
"groqApiKey": "Khóa API Groq",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/zh-CN/settings.json
generated
3
webview-ui/src/i18n/locales/zh-CN/settings.json
generated
|
|
@ -293,6 +293,9 @@
|
|||
"moonshotApiKey": "Moonshot API 密钥",
|
||||
"getMoonshotApiKey": "获取 Moonshot API 密钥",
|
||||
"moonshotBaseUrl": "Moonshot 服务站点",
|
||||
"minimaxApiKey": "MiniMax API 密钥",
|
||||
"getMiniMaxApiKey": "获取 MiniMax API 密钥",
|
||||
"minimaxBaseUrl": "MiniMax 服务站点",
|
||||
"zaiApiKey": "Z AI API 密钥",
|
||||
"getZaiApiKey": "获取 Z AI API 密钥",
|
||||
"zaiEntrypoint": "Z AI 服务站点",
|
||||
|
|
|
|||
3
webview-ui/src/i18n/locales/zh-TW/settings.json
generated
3
webview-ui/src/i18n/locales/zh-TW/settings.json
generated
|
|
@ -297,6 +297,9 @@
|
|||
"getZaiApiKey": "取得 Z AI API 金鑰",
|
||||
"zaiEntrypoint": "Z AI 服務端點",
|
||||
"zaiEntrypointDescription": "請根據您的位置選擇適當的 API 服務端點。如果您在中國,請選擇 open.bigmodel.cn。否則,請選擇 api.z.ai。",
|
||||
"minimaxApiKey": "MiniMax API 金鑰",
|
||||
"getMiniMaxApiKey": "取得 MiniMax API 金鑰",
|
||||
"minimaxBaseUrl": "MiniMax 服務端點",
|
||||
"geminiApiKey": "Gemini API 金鑰",
|
||||
"getGroqApiKey": "取得 Groq API 金鑰",
|
||||
"groqApiKey": "Groq API 金鑰",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue