diff --git a/src/cli/__tests__/model-providers.test.ts b/src/cli/__tests__/model-providers.test.ts new file mode 100644 index 0000000..85c1432 --- /dev/null +++ b/src/cli/__tests__/model-providers.test.ts @@ -0,0 +1,137 @@ +import Anthropic from "@anthropic-ai/sdk"; +import { describe, expect, test } from "bun:test"; +import { OpenAI } from "openai"; + +import { getDefaultModelOptions, MODEL_PROVIDERS } from "../model-providers"; + +const MINIMAX_OPENAI_PROVIDERS = [ + { + label: "MiniMax (Domestic)", + id: "minimax_cn", + baseURL: "https://api.minimaxi.com/v1", + }, + { + label: "MiniMax (Global)", + id: "minimax_global", + baseURL: "https://api.minimax.io/v1", + }, +] as const; + +const MINIMAX_ANTHROPIC_PROVIDERS = [ + { + label: "MiniMax Anthropic (Domestic)", + id: "minimax_anthropic_cn", + baseURL: "https://api.minimaxi.com/anthropic", + }, + { + label: "MiniMax Anthropic (Global)", + id: "minimax_anthropic_global", + baseURL: "https://api.minimax.io/anthropic", + }, +] as const; + +const MINIMAX_MODEL_IDS = ["MiniMax-M3", "MiniMax-M2.7"] as const; + +describe("MiniMax providers", () => { + test("use current endpoints and defaults", () => { + for (const expected of MINIMAX_OPENAI_PROVIDERS) { + expect(MODEL_PROVIDERS.find(({ id }) => id === expected.id)).toMatchObject({ + ...expected, + providerType: "openai", + defaultModelName: "MiniMax-M3", + }); + } + for (const expected of MINIMAX_ANTHROPIC_PROVIDERS) { + expect(MODEL_PROVIDERS.find(({ id }) => id === expected.id)).toMatchObject({ + ...expected, + providerType: "anthropic", + defaultModelName: "MiniMax-M3", + }); + } + }); + + test("use API-compatible thinking defaults", () => { + expect(getDefaultModelOptions("MiniMax-M3", "openai")).toEqual({ + max_tokens: 16 * 1024, + thinking: { type: "adaptive" }, + }); + expect(getDefaultModelOptions("MiniMax-M3", "anthropic")).toEqual({ max_tokens: 16 * 1024 }); + expect(getDefaultModelOptions("MiniMax-M2.7", "openai")).toEqual({ max_tokens: 16 * 1024 }); + expect(getDefaultModelOptions("MiniMax-M2.7", "anthropic")).toEqual({ max_tokens: 16 * 1024 }); + expect(getDefaultModelOptions("custom-model", "anthropic")).toEqual({ + max_tokens: 16 * 1024, + thinking: { type: "enabled" }, + }); + }); + + for (const provider of MINIMAX_OPENAI_PROVIDERS) { + for (const model of MINIMAX_MODEL_IDS) { + test(`${provider.id} sends ${model} to one /chat/completions path`, async () => { + let requestURL = ""; + const client = new OpenAI({ + apiKey: "test-api-key", + baseURL: provider.baseURL, + fetch: async (input) => { + requestURL = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return new Response( + JSON.stringify({ + id: "chatcmpl_test", + object: "chat.completion", + created: 0, + model, + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { headers: { "content-type": "application/json" } }, + ); + }, + }); + + await client.chat.completions.create({ + model, + messages: [{ role: "user", content: "test" }], + }); + + expect(requestURL).toBe(`${provider.baseURL}/chat/completions`); + expect(requestURL.match(/\/chat\/completions/g)).toHaveLength(1); + }); + } + } + + for (const provider of MINIMAX_ANTHROPIC_PROVIDERS) { + for (const model of MINIMAX_MODEL_IDS) { + test(`${provider.id} sends ${model} to one /v1/messages path`, async () => { + let requestURL = ""; + const client = new Anthropic({ + apiKey: "test-api-key", + baseURL: provider.baseURL, + fetch: async (input) => { + requestURL = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + return new Response( + JSON.stringify({ + id: "msg_test", + type: "message", + role: "assistant", + model, + content: [], + stop_reason: "end_turn", + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }), + { headers: { "content-type": "application/json" } }, + ); + }, + }); + + await client.messages.create({ + model, + max_tokens: 1, + messages: [{ role: "user", content: "test" }], + }); + + expect(requestURL).toBe(`${provider.baseURL}/v1/messages`); + expect(requestURL.match(/\/v1\/messages/g)).toHaveLength(1); + }); + } + } +}); diff --git a/src/cli/bootstrap/model-wizard.tsx b/src/cli/bootstrap/model-wizard.tsx index 5b9bd61..6e0f022 100644 --- a/src/cli/bootstrap/model-wizard.tsx +++ b/src/cli/bootstrap/model-wizard.tsx @@ -27,8 +27,11 @@ type ModelWizardProps = { function ModelWizard({ onComplete, onAbort }: ModelWizardProps) { const [step, setStep] = useState("provider"); const [providerIndex, setProviderIndex] = useState(0); + const selectedProvider = MODEL_PROVIDERS[providerIndex]!; const [apiKey, setApiKey] = useState(""); const [modelName, setModelName] = useState(""); + const suggestedModelName = selectedProvider.defaultModelName ?? ""; + const effectiveModelName = modelName.trim() || suggestedModelName; const [customBaseURL, setCustomBaseURL] = useState(""); const [pendingEntry, setPendingEntry] = useState(null); @@ -72,11 +75,9 @@ function ModelWizard({ onComplete, onAbort }: ModelWizardProps) { { isActive: step === "provider" }, ); - const selectedProvider = MODEL_PROVIDERS[providerIndex]!; - const finishWithBaseURL = (url: string) => { setCustomBaseURL(url); - const entry = buildModelEntry(url, apiKey, modelName, selectedProvider.providerType); + const entry = buildModelEntry(url, apiKey, effectiveModelName, selectedProvider.providerType); setPendingEntry(entry); setStep("confirm"); }; @@ -85,7 +86,7 @@ function ModelWizard({ onComplete, onAbort }: ModelWizardProps) { if (!selectedProvider.baseURL) { setStep("baseURL"); } else { - const entry = buildModelEntry(selectedProvider.baseURL, apiKey, modelName, selectedProvider.providerType); + const entry = buildModelEntry(selectedProvider.baseURL, apiKey, effectiveModelName, selectedProvider.providerType); setPendingEntry(entry); setStep("confirm"); } @@ -142,12 +143,12 @@ function ModelWizard({ onComplete, onAbort }: ModelWizardProps) { if (step === "modelName") { return ( - Enter a model name + Enter a model name{suggestedModelName ? ` (default: ${suggestedModelName})` : ""} Model: diff --git a/src/cli/index.tsx b/src/cli/index.tsx index 006c6f4..50fde83 100644 --- a/src/cli/index.tsx +++ b/src/cli/index.tsx @@ -13,6 +13,7 @@ import { OpenAIModelProvider } from "@/community/openai"; import type { ModelProvider } from "@/foundation"; import { Model } from "@/foundation"; +import { getDefaultModelOptions } from "./model-providers"; import { App } from "./tui"; import { loadAvailableCommands, type SlashCommand } from "./tui/command-registry"; import { AgentLoopProvider } from "./tui/hooks/use-agent-loop"; @@ -54,12 +55,7 @@ if (args.length > 0) { }); } - const model = new Model(entry.name, provider, { - max_tokens: 16 * 1024, - thinking: { - type: "enabled", - }, - }); + const model = new Model(entry.name, provider, getDefaultModelOptions(entry.name, entry.provider)); const skillsDirs = [ join(process.cwd(), "skills"), diff --git a/src/cli/model-providers.ts b/src/cli/model-providers.ts index 8b14b70..37cb384 100644 --- a/src/cli/model-providers.ts +++ b/src/cli/model-providers.ts @@ -5,8 +5,23 @@ export type ModelProviderConfig = { id: string; baseURL: string; providerType: ProviderType; + defaultModelName?: string; }; +export function getDefaultModelOptions(modelName: string, providerType: ProviderType): { + max_tokens: number; + thinking?: { type: "adaptive" | "enabled" }; +} { + const options = { max_tokens: 16 * 1024 }; + if (modelName === "MiniMax-M3") { + return providerType === "openai" ? { ...options, thinking: { type: "adaptive" } } : options; + } + if (modelName.startsWith("MiniMax-M2")) { + return options; + } + return { ...options, thinking: { type: "enabled" } }; +} + export const MODEL_PROVIDERS: ModelProviderConfig[] = [ { label: "Anthropic (Claude)", id: "anthropic", baseURL: "https://api.anthropic.com", providerType: "anthropic" }, { label: "OpenAI", id: "openai", baseURL: "https://api.openai.com/v1", providerType: "openai" }, @@ -18,8 +33,34 @@ export const MODEL_PROVIDERS: ModelProviderConfig[] = [ providerType: "openai", }, { label: "Qwen (Aliyun)", id: "qwen", baseURL: "https://dashscope.aliyuncs.com/compatible-mode/v1", providerType: "openai" }, - { label: "Minimax (Domestic)", id: "minimax_cn", baseURL: "https://api.minimaxi.com/v1", providerType: "openai" }, - { label: "Minimax (Global)", id: "minimax_global", baseURL: "https://api.minimax.io/v1", providerType: "openai" }, + { + label: "MiniMax (Domestic)", + id: "minimax_cn", + baseURL: "https://api.minimaxi.com/v1", + providerType: "openai", + defaultModelName: "MiniMax-M3", + }, + { + label: "MiniMax (Global)", + id: "minimax_global", + baseURL: "https://api.minimax.io/v1", + providerType: "openai", + defaultModelName: "MiniMax-M3", + }, + { + label: "MiniMax Anthropic (Domestic)", + id: "minimax_anthropic_cn", + baseURL: "https://api.minimaxi.com/anthropic", + providerType: "anthropic", + defaultModelName: "MiniMax-M3", + }, + { + label: "MiniMax Anthropic (Global)", + id: "minimax_anthropic_global", + baseURL: "https://api.minimax.io/anthropic", + providerType: "anthropic", + defaultModelName: "MiniMax-M3", + }, { label: "GLM (Zhipu AI)", id: "glm", baseURL: "https://open.bigmodel.cn/api/paas/v4", providerType: "openai" }, { label: "Kimi (Moonshot)", id: "kimi", baseURL: "https://api.moonshot.cn/v1", providerType: "openai" }, { label: "DeepSeek (OpenAI compatible)", id: "deepseek", baseURL: "https://api.deepseek.com/v1", providerType: "openai" },