From 336d63b6d5d69af0565dcee90f7cf8133eef7233 Mon Sep 17 00:00:00 2001 From: octo-patch <266937838+octo-patch@users.noreply.github.com> Date: Wed, 15 Jul 2026 14:45:33 +0800 Subject: [PATCH] feat: expand MiniMax provider registry --- backend/app/services/llm/client.py | 137 +++++++++++++++++- backend/tests/test_llm_provider_registry.py | 56 +++++++ .../pages/enterprise-settings/tabs/LlmTab.tsx | 69 ++++++++- 3 files changed, 254 insertions(+), 8 deletions(-) create mode 100644 backend/tests/test_llm_provider_registry.py diff --git a/backend/app/services/llm/client.py b/backend/app/services/llm/client.py index 1cdcfeda4..06fa8525c 100644 --- a/backend/app/services/llm/client.py +++ b/backend/app/services/llm/client.py @@ -1896,6 +1896,28 @@ async def close(self) -> None: # Factory and Utilities # ============================================================================ +@dataclass(frozen=True) +class ProviderModelSpec: + """Model metadata exposed through the provider manifest.""" + + model_id: str + context_window: int | None = None + pricing_usd_per_million_tokens: dict[str, float | None] = field(default_factory=dict) + pricing_tiers_usd_per_million_tokens: tuple[dict[str, Any], ...] = () + input_modalities: tuple[str, ...] = () + thinking: tuple[str, ...] = () + + +@dataclass(frozen=True) +class ProviderEndpoint: + """Regional protocol endpoints exposed through the provider manifest.""" + + region: str + openai_base_url: str + anthropic_base_url: str + docs_root: str | None = None + + @dataclass(frozen=True) class ProviderSpec: """Provider registry entry.""" @@ -1907,6 +1929,9 @@ class ProviderSpec: supports_tool_choice: bool = True default_max_tokens: int = 4096 model_max_tokens: dict[str, int] = field(default_factory=dict) + default_model_id: str | None = None + models: tuple[ProviderModelSpec, ...] = () + endpoints: tuple[ProviderEndpoint, ...] = () # Provider aliases accepted for compatibility @@ -1971,8 +1996,83 @@ class ProviderSpec: provider="minimax", display_name="MiniMax", protocol="openai_compatible", - default_base_url="https://api.minimaxi.com/v1", + default_base_url="https://api.minimax.io/v1", default_max_tokens=16384, + default_model_id="MiniMax-M3", + models=( + ProviderModelSpec( + model_id="MiniMax-M3", + context_window=1000000, + pricing_usd_per_million_tokens={ + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": None, + }, + pricing_tiers_usd_per_million_tokens=( + { + "service_tier": "standard", + "input_tokens_lte": 512000, + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": None, + }, + { + "service_tier": "standard", + "input_tokens_gt": 512000, + "input": 0.6, + "output": 2.4, + "cache_read": 0.12, + "cache_write": None, + }, + { + "service_tier": "priority", + "input_tokens_lte": 512000, + "input": 0.45, + "output": 1.8, + "cache_read": 0.09, + "cache_write": None, + }, + { + "service_tier": "priority", + "input_tokens_gt": 512000, + "input": 0.9, + "output": 3.6, + "cache_read": 0.18, + "cache_write": None, + }, + ), + input_modalities=("text", "image", "video"), + thinking=("adaptive", "disabled"), + ), + ProviderModelSpec( + model_id="MiniMax-M2.7", + context_window=204800, + pricing_usd_per_million_tokens={ + "input": 0.3, + "output": 1.2, + "cache_read": 0.06, + "cache_write": 0.375, + }, + input_modalities=("text",), + thinking=("always_on",), + ), + ), + endpoints=( + ProviderEndpoint( + region="global_en", + openai_base_url="https://api.minimax.io/v1", + anthropic_base_url="https://api.minimax.io/anthropic", + docs_root="https://platform.minimax.io/docs", + ), + ProviderEndpoint( + region="cn_zh", + openai_base_url="https://api.minimaxi.com/v1", + anthropic_base_url="https://api.minimaxi.com/anthropic", + docs_root="https://platform.minimaxi.com/docs", + ), + ), ), "openrouter": ProviderSpec( provider="openrouter", @@ -2065,6 +2165,28 @@ def get_provider_manifest() -> list[dict[str, Any]]: "default_max_tokens": spec.default_max_tokens, "model_max_tokens": spec.model_max_tokens, "aliases": [k for k, v in PROVIDER_ALIASES.items() if v == spec.provider], + "default_model_id": spec.default_model_id, + "model_ids": [model.model_id for model in spec.models], + "models": [ + { + "model_id": model.model_id, + "context_window": model.context_window, + "pricing_usd_per_million_tokens": model.pricing_usd_per_million_tokens, + "pricing_tiers_usd_per_million_tokens": list(model.pricing_tiers_usd_per_million_tokens), + "input_modalities": list(model.input_modalities), + "thinking": list(model.thinking), + } + for model in spec.models + ], + "endpoints": [ + { + "region": endpoint.region, + "openai_base_url": endpoint.openai_base_url, + "anthropic_base_url": endpoint.anthropic_base_url, + "docs_root": endpoint.docs_root, + } + for endpoint in spec.endpoints + ], }) return out @@ -2146,6 +2268,17 @@ def get_max_tokens(provider: str, model: str | None = None, max_output_tokens: i return MAX_TOKENS_BY_PROVIDER.get(normalize_provider(provider), 4096) +def _uses_anthropic_endpoint(spec: ProviderSpec | None, base_url: str | None) -> bool: + """Detect a configured native Anthropic-compatible endpoint for a provider.""" + if not spec or not base_url: + return False + normalized_base_url = base_url.rstrip("/") + return any( + normalized_base_url == endpoint.anthropic_base_url.rstrip("/") + for endpoint in spec.endpoints + ) + + def create_llm_client( provider: str, api_key: str, @@ -2175,7 +2308,7 @@ def create_llm_client( final_base_url = get_provider_base_url(normalized_provider, base_url) # Create appropriate client - if spec and spec.protocol == "anthropic": + if spec and (spec.protocol == "anthropic" or _uses_anthropic_endpoint(spec, final_base_url)): return AnthropicClient( api_key=api_key, base_url=final_base_url, diff --git a/backend/tests/test_llm_provider_registry.py b/backend/tests/test_llm_provider_registry.py new file mode 100644 index 000000000..81c316e22 --- /dev/null +++ b/backend/tests/test_llm_provider_registry.py @@ -0,0 +1,56 @@ +from app.services.llm.client import ( + AnthropicClient, + OpenAICompatibleClient, + PROVIDER_REGISTRY, + create_llm_client, + get_provider_manifest, +) + + +def test_minimax_registry_contains_target_models_and_endpoints(): + spec = PROVIDER_REGISTRY["minimax"] + + assert spec.default_model_id == "MiniMax-M3" + assert [model.model_id for model in spec.models] == ["MiniMax-M3", "MiniMax-M2.7"] + assert {endpoint.region for endpoint in spec.endpoints} == {"global_en", "cn_zh"} + assert { + (endpoint.openai_base_url, endpoint.anthropic_base_url) + for endpoint in spec.endpoints + } == { + ("https://api.minimax.io/v1", "https://api.minimax.io/anthropic"), + ("https://api.minimaxi.com/v1", "https://api.minimaxi.com/anthropic"), + } + assert all(endpoint.anthropic_base_url.endswith("/anthropic") for endpoint in spec.endpoints) + + +def test_minimax_manifest_exposes_model_metadata_and_endpoint_choices(): + manifest = next(item for item in get_provider_manifest() if item["provider"] == "minimax") + + assert manifest["model_ids"] == ["MiniMax-M3", "MiniMax-M2.7"] + assert manifest["models"][0]["context_window"] == 1000000 + assert manifest["models"][0]["input_modalities"] == ["text", "image", "video"] + assert manifest["models"][1]["thinking"] == ["always_on"] + assert {endpoint["region"] for endpoint in manifest["endpoints"]} == {"global_en", "cn_zh"} + + +def test_minimax_anthropic_base_uses_anthropic_client(): + client = create_llm_client( + provider="minimax", + api_key="test-key", + model="MiniMax-M3", + base_url="https://api.minimax.io/anthropic", + ) + + assert isinstance(client, AnthropicClient) + assert client._normalize_base_url() == "https://api.minimax.io/anthropic" + + +def test_minimax_openai_base_uses_openai_compatible_client(): + client = create_llm_client( + provider="minimax", + api_key="test-key", + model="MiniMax-M3", + base_url="https://api.minimaxi.com/v1", + ) + + assert isinstance(client, OpenAICompatibleClient) diff --git a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx index 7863fd1c3..1925c35ce 100644 --- a/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx +++ b/frontend/src/pages/enterprise-settings/tabs/LlmTab.tsx @@ -30,6 +30,16 @@ interface LLMProviderSpec { default_base_url?: string | null; supports_tool_choice: boolean; default_max_tokens: number; + default_model_id?: string | null; + model_ids?: string[]; + endpoints?: LLMProviderEndpoint[]; +} + +interface LLMProviderEndpoint { + region: string; + openai_base_url: string; + anthropic_base_url: string; + docs_root?: string | null; } const FALLBACK_LLM_PROVIDERS: LLMProviderSpec[] = [ @@ -37,7 +47,30 @@ const FALLBACK_LLM_PROVIDERS: LLMProviderSpec[] = [ { provider: 'openai', display_name: 'OpenAI', protocol: 'openai_compatible', default_base_url: 'https://api.openai.com/v1', supports_tool_choice: true, default_max_tokens: 16384 }, { provider: 'azure', display_name: 'Azure OpenAI', protocol: 'openai_compatible', default_base_url: '', supports_tool_choice: true, default_max_tokens: 16384 }, { provider: 'deepseek', display_name: 'DeepSeek', protocol: 'openai_compatible', default_base_url: 'https://api.deepseek.com/v1', supports_tool_choice: true, default_max_tokens: 8192 }, - { provider: 'minimax', display_name: 'MiniMax', protocol: 'openai_compatible', default_base_url: 'https://api.minimaxi.com/v1', supports_tool_choice: true, default_max_tokens: 16384 }, + { + provider: 'minimax', + display_name: 'MiniMax', + protocol: 'openai_compatible', + default_base_url: 'https://api.minimax.io/v1', + supports_tool_choice: true, + default_max_tokens: 16384, + default_model_id: 'MiniMax-M3', + model_ids: ['MiniMax-M3', 'MiniMax-M2.7'], + endpoints: [ + { + region: 'global_en', + openai_base_url: 'https://api.minimax.io/v1', + anthropic_base_url: 'https://api.minimax.io/anthropic', + docs_root: 'https://platform.minimax.io/docs', + }, + { + region: 'cn_zh', + openai_base_url: 'https://api.minimaxi.com/v1', + anthropic_base_url: 'https://api.minimaxi.com/anthropic', + docs_root: 'https://platform.minimaxi.com/docs', + }, + ], + }, { provider: 'qwen', display_name: 'Qwen (DashScope)', protocol: 'openai_compatible', default_base_url: 'https://dashscope.aliyuncs.com/compatible-mode/v1', supports_tool_choice: true, default_max_tokens: 8192 }, { provider: 'zhipu', display_name: 'Zhipu', protocol: 'openai_compatible', default_base_url: 'https://open.bigmodel.cn/api/paas/v4', supports_tool_choice: true, default_max_tokens: 8192 }, { provider: 'baidu', display_name: 'Baidu (Qianfan)', protocol: 'openai_compatible', default_base_url: 'https://qianfan.baidubce.com/v2', supports_tool_choice: false, default_max_tokens: 4096 }, @@ -91,6 +124,14 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { queryFn: () => fetchJson('/enterprise/llm-providers'), }); const providerOptions = providerSpecs.length > 0 ? providerSpecs : FALLBACK_LLM_PROVIDERS; + const activeProviderSpec = providerOptions.find((p) => p.provider === modelForm.provider); + const modelOptions = activeProviderSpec?.model_ids || []; + const endpointOptions = Array.from(new Set( + (activeProviderSpec?.endpoints || []).flatMap((endpoint) => [ + endpoint.openai_base_url, + endpoint.anthropic_base_url, + ]), + )); const addModel = useMutation({ mutationFn: (data: any) => fetchJson(`/enterprise/llm-models${selectedTenantId ? `?tenant_id=${selectedTenantId}` : ''}`, { method: 'POST', body: JSON.stringify(data) }), @@ -248,7 +289,11 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) { const updates: any = { provider: newProvider }; updates.base_url = spec?.default_base_url || ''; if (spec) updates.max_output_tokens = String(spec.default_max_tokens); - setModelForm(f => ({ ...f, ...updates })); + setModelForm(f => ({ + ...f, + ...updates, + model: f.model || spec?.default_model_id || '', + })); }}> {providerOptions.map((p) => ( @@ -257,7 +302,10 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
- setModelForm({ ...modelForm, model: e.target.value })} /> + setModelForm({ ...modelForm, model: e.target.value })} /> + + {modelOptions.map((modelId) =>
@@ -265,7 +313,10 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
- setModelForm({ ...modelForm, base_url: e.target.value })} /> + setModelForm({ ...modelForm, base_url: e.target.value })} /> + + {endpointOptions.map((endpoint) =>
@@ -334,7 +385,10 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
- setModelForm({ ...modelForm, model: e.target.value })} /> + setModelForm({ ...modelForm, model: e.target.value })} /> + + {modelOptions.map((modelId) =>
@@ -342,7 +396,10 @@ export default function LlmTab({ selectedTenantId }: LlmTabProps) {
- setModelForm({ ...modelForm, base_url: e.target.value })} /> + setModelForm({ ...modelForm, base_url: e.target.value })} /> + + {endpointOptions.map((endpoint) =>