diff --git a/.env.example b/.env.example index 10e88aa00..be1bb091d 100644 --- a/.env.example +++ b/.env.example @@ -2,7 +2,7 @@ ENV=dev # options: dev|s # Active LLM provider. Selects which provider answers credentials, # metadata, and default-model lookups. Leave unset to default to nv_build. -# Options: openai | anthropic | anthropic_proxy | bedrock | nv_build | +# Options: openai | anthropic | anthropic_proxy | bedrock | minimax | nv_build | # ollama | azure_openai | openai_compatible | gemini | claude_cli | # gemini_cli | opencode_cli # codex_cli is registered but disabled: its read-only sandbox permits host-file reads. @@ -50,6 +50,13 @@ SKILLSPECTOR_SEED= # integer # For SKILLSPECTOR_PROVIDER=anthropic. ANTHROPIC_API_KEY= +# For SKILLSPECTOR_PROVIDER=minimax. MINIMAX_REGION accepts global_en or cn_zh. +# Defaults to MiniMax-M3.1-Flash; SKILLSPECTOR_MODEL can select MiniMax-M3 or an explicit model. +# Flash accepts reasoning effort low, medium, high, xhigh or max (endpoint default). +MINIMAX_API_KEY= +MINIMAX_REGION=global_en +# MINIMAX_BASE_URL= # optional override + # For SKILLSPECTOR_PROVIDER=anthropic_proxy (Vertex-style raw-predict proxy). # Supports corporate API gateways, GCP Vertex AI, and self-hosted proxies. ANTHROPIC_PROXY_ENDPOINT_URL= diff --git a/README.md b/README.md index 8bf5e384f..9b9f97982 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,7 @@ inference gateways. | `openai` | `OPENAI_API_KEY` (+ optional `OPENAI_BASE_URL`) | api.openai.com (or any OpenAI-compatible URL) | `gpt-5.4` | | `anthropic` | `ANTHROPIC_API_KEY` | api.anthropic.com | `claude-opus-4-6` (`meta_analyzer`: `claude-sonnet-4-6`) | | `anthropic_proxy` | `ANTHROPIC_PROXY_API_KEY` + `ANTHROPIC_PROXY_ENDPOINT_URL` | Any Vertex-style raw-predict proxy | `claude-sonnet-4-6` | +| `minimax` | `MINIMAX_API_KEY` (+ optional `MINIMAX_REGION`) | Global or China regional endpoint | `MiniMax-M3.1-Flash` | | `bedrock` | `AWS_PROFILE` (optional) + `AWS_REGION` — SigV4 via boto3 | AWS Bedrock Runtime | `us.anthropic.claude-sonnet-4-6` | | `nv_build` | `NVIDIA_INFERENCE_KEY` | build.nvidia.com | `z-ai/glm-5.3` | | `gemini` | `GOOGLE_CLOUD_PROJECT` (+ optional `GOOGLE_CLOUD_LOCATION`) via ADC | Google Cloud OpenAI-compatible Gemini endpoint | `gemini-3.8-flash` | @@ -380,6 +381,15 @@ export SKILLSPECTOR_PROVIDER=nv_build export NVIDIA_INFERENCE_KEY=nvapi-... skillspector scan ./my-skill/ +# MiniMax global endpoint; use cn_zh for the China endpoint +export SKILLSPECTOR_PROVIDER=minimax +export MINIMAX_API_KEY=your-key +export MINIMAX_REGION=global_en +# Default: MiniMax-M3.1-Flash; set SKILLSPECTOR_MODEL=MiniMax-M3 to select M3. +# Flash reasoning effort: low, medium, high, xhigh, max (endpoint default). +# Explicit legacy/custom model IDs remain supported through SKILLSPECTOR_MODEL. +skillspector scan ./my-skill/ + # Gemini on Google Cloud (Application Default Credentials / Workload Identity) # Prerequisites: # 1. Google Cloud project with billing enabled. @@ -734,7 +744,7 @@ Issues (2) | Variable | Description | Required | |----------|-------------|----------| -| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `nv_build`, `gemini`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `gemini_cli`, or `opencode_cli`. Hosted providers use bundled `model_registry.yaml` defaults; CLI providers fall back to the local runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional | +| `SKILLSPECTOR_PROVIDER` | Active LLM provider: `openai`, `anthropic`, `anthropic_proxy`, `bedrock`, `minimax`, `nv_build`, `gemini`, `ollama`, `azure_openai`, `openai_compatible`, `claude_cli`, `gemini_cli`, or `opencode_cli`. Hosted providers use bundled `model_registry.yaml` defaults; CLI providers fall back to the local runtime's default model unless `SKILLSPECTOR_MODEL` is set. Defaults to `nv_build`. | Optional | | `GOOGLE_CLOUD_PROJECT` | Google Cloud project ID for the `gemini` provider. Authenticates via Google Cloud Application Default Credentials (ADC) or GKE Workload Identity. | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=gemini` | | `GOOGLE_CLOUD_LOCATION` | Google Cloud location for the `gemini` provider endpoint (e.g. `global`, `us`, `eu`, `us-central1`). Defaults to `global`. | Optional (used when `SKILLSPECTOR_PROVIDER=gemini`) | | `GOOGLE_APPLICATION_CREDENTIALS` | Optional path to ADC credential/config file (e.g. Workload or Workforce Identity Federation config; exported service account keys are discouraged). For local development, use `gcloud auth application-default login`; for GKE, use Workload Identity. | Optional (used when `SKILLSPECTOR_PROVIDER=gemini`) | @@ -748,6 +758,9 @@ Issues (2) | `SKILLSPECTOR_COMPACT_PROMPTS` | Opt-in compact line numbering in LLM prompts: numbered lines render as `L1:`, `L2:` instead of zero-padded `L01:`, `L02:`. Accepted truthy values are `1`, `true`, and `yes` (case-insensitive; surrounding whitespace is trimmed). Unset or any other value keeps the default zero-padded format. | Optional | | `ANTHROPIC_API_KEY` | Credential for the Anthropic provider (`SKILLSPECTOR_PROVIDER=anthropic`). | Required for LLM analysis when `SKILLSPECTOR_PROVIDER=anthropic` | | `ANTHROPIC_BASE_URL` | Override the native Anthropic endpoint (default: `https://api.anthropic.com`). | Optional | +| `MINIMAX_API_KEY` | Credential for the MiniMax provider (`SKILLSPECTOR_PROVIDER=minimax`). | Required when `SKILLSPECTOR_PROVIDER=minimax` | +| `MINIMAX_REGION` | MiniMax regional endpoint: `global_en` (default) or `cn_zh`. | Optional | +| `MINIMAX_BASE_URL` | Override the selected MiniMax regional endpoint. | Optional | | `ANTHROPIC_AUTH_SCHEME` | Set to `bearer` to send `ANTHROPIC_API_KEY` as `Authorization: Bearer` instead of `x-api-key`, for gateways exposing the Messages API. | Optional | | `ANTHROPIC_PROXY_ENDPOINT_URL` | Full endpoint URL for the Anthropic proxy provider (Vertex-style raw-predict). | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | | `ANTHROPIC_PROXY_API_KEY` | Bearer token for the Anthropic proxy provider. | Required when `SKILLSPECTOR_PROVIDER=anthropic_proxy` | diff --git a/src/skillspector/cli.py b/src/skillspector/cli.py index c86520896..2bb029de3 100644 --- a/src/skillspector/cli.py +++ b/src/skillspector/cli.py @@ -643,7 +643,7 @@ def scan( Environment variables: SKILLSPECTOR_PROVIDER Active LLM provider: openai | anthropic | - anthropic_proxy | bedrock | nv_build | + anthropic_proxy | bedrock | minimax | nv_build | nv_inference | ollama | azure_openai | openai_compatible | gemini | claude_cli | gemini_cli | opencode_cli. Defaults to the NVIDIA path @@ -657,6 +657,7 @@ def scan( OPENAI_API_KEY [+ OPENAI_BASE_URL] for SKILLSPECTOR_PROVIDER=openai ANTHROPIC_API_KEY for SKILLSPECTOR_PROVIDER=anthropic + MINIMAX_API_KEY for SKILLSPECTOR_PROVIDER=minimax ANTHROPIC_PROXY_API_KEY + ANTHROPIC_PROXY_ENDPOINT_URL for anthropic_proxy AWS_PROFILE (optional) + AWS_REGION for SKILLSPECTOR_PROVIDER=bedrock diff --git a/src/skillspector/inference_usage.py b/src/skillspector/inference_usage.py index 933ac58bb..2aff55cac 100644 --- a/src/skillspector/inference_usage.py +++ b/src/skillspector/inference_usage.py @@ -328,6 +328,7 @@ def provider_name(provider: object) -> str: "CodexCLIProvider": "codex_cli", "GeminiCLIProvider": "gemini_cli", "GeminiProvider": "gemini", + "MiniMaxProvider": "minimax", "NvBuildProvider": "nv_build", "NvInferenceProvider": "nv_inference", "OllamaProvider": "ollama", diff --git a/src/skillspector/llm_provenance.py b/src/skillspector/llm_provenance.py index 23157c40c..3a9c8279c 100644 --- a/src/skillspector/llm_provenance.py +++ b/src/skillspector/llm_provenance.py @@ -37,6 +37,7 @@ "anthropic_proxy", "azure_openai", "bedrock", + "minimax", "nv_build", "nv_inference", "ollama", @@ -45,12 +46,13 @@ } ) _SEED_ADAPTERS = frozenset( - {"azure_openai", "nv_build", "nv_inference", "ollama", "openai", "openai_compatible"} + {"azure_openai", "minimax", "nv_build", "nv_inference", "ollama", "openai", "openai_compatible"} ) _REASONING_EFFORT_ADAPTERS = frozenset( { "anthropic", "anthropic_proxy", + "minimax", "nv_build", "nv_inference", "ollama", diff --git a/src/skillspector/providers/__init__.py b/src/skillspector/providers/__init__.py index 2c2bd7e02..f0d4062e8 100644 --- a/src/skillspector/providers/__init__.py +++ b/src/skillspector/providers/__init__.py @@ -26,6 +26,7 @@ anthropic → AnthropicProvider (api.anthropic.com) anthropic_proxy → AnthropicProxyProvider (Vertex-style raw-predict proxy) bedrock → BedrockProvider (AWS Bedrock Runtime, SigV4) + minimax → MiniMaxProvider (global or China regional endpoint) nv_build → NvBuildProvider (build.nvidia.com) ollama → OllamaProvider (local Ollama instance) azure_openai → AzureOpenAIProvider (Azure OpenAI Service) @@ -127,6 +128,10 @@ def _select_active_provider() -> LLMProvider: from .bedrock import BedrockProvider return BedrockProvider() + if name == "minimax": + from .minimax import MiniMaxProvider + + return MiniMaxProvider() if name == "ollama": from .ollama import OllamaProvider @@ -177,7 +182,7 @@ def _select_active_provider() -> LLMProvider: raise UnknownProviderError( f"Unknown SKILLSPECTOR_PROVIDER: {name!r}. " - "Expected one of: openai, anthropic, anthropic_proxy, bedrock, nv_build, " + "Expected one of: openai, anthropic, anthropic_proxy, bedrock, minimax, nv_build, " "ollama, azure_openai, openai_compatible, gemini, " "claude_cli, codex_cli, gemini_cli, opencode_cli, antigravity_cli (or unset)." ) diff --git a/src/skillspector/providers/chat_models.py b/src/skillspector/providers/chat_models.py index e1366bad3..0fcdc2b54 100644 --- a/src/skillspector/providers/chat_models.py +++ b/src/skillspector/providers/chat_models.py @@ -183,6 +183,7 @@ def create_openai_compatible_chat_model( default_headers: dict[str, str] | None = None, forced_tool_choice: bool = True, default_reasoning_effort: str | None = None, + chat_model_class: type[ChatOpenAI] | None = None, ) -> BaseChatModel | None: """Create ``ChatOpenAI`` for providers serving OpenAI-compatible endpoints. @@ -212,7 +213,7 @@ def create_openai_compatible_chat_model( model, include_seed=True, reasoning_effort=reasoning_effort ) kwargs.update(sampling_parameters) - chat_model = ChatOpenAI(**kwargs) + chat_model = (chat_model_class or ChatOpenAI)(**kwargs) register_chat_model_controls( chat_model, retained_chat_model_controls( diff --git a/src/skillspector/providers/minimax/__init__.py b/src/skillspector/providers/minimax/__init__.py new file mode 100644 index 000000000..c363cabac --- /dev/null +++ b/src/skillspector/providers/minimax/__init__.py @@ -0,0 +1,30 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""MiniMax provider package.""" + +from .provider import ( + MINIMAX_CN_BASE_URL, + MINIMAX_GLOBAL_BASE_URL, + REGISTRY_PATH, + MiniMaxProvider, +) + +__all__ = [ + "MINIMAX_CN_BASE_URL", + "MINIMAX_GLOBAL_BASE_URL", + "REGISTRY_PATH", + "MiniMaxProvider", +] diff --git a/src/skillspector/providers/minimax/chat_model.py b/src/skillspector/providers/minimax/chat_model.py new file mode 100644 index 000000000..502081a12 --- /dev/null +++ b/src/skillspector/providers/minimax/chat_model.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Preserve MiniMax reasoning across OpenAI-compatible tool continuations.""" + +from typing import Any + +import openai +from langchain_core.language_models import LanguageModelInput +from langchain_core.messages import AIMessage +from langchain_core.outputs import ChatGenerationChunk, ChatResult +from langchain_openai import ChatOpenAI + + +class MiniMaxChatOpenAI(ChatOpenAI): + """Keep the reasoning field that generic ChatOpenAI intentionally discards.""" + + def _get_request_payload( + self, + input_: LanguageModelInput, + *, + stop: list[str] | None = None, + **kwargs: Any, + ) -> dict: + messages = self._convert_input(input_).to_messages() + payload = super()._get_request_payload(messages, stop=stop, **kwargs) + for message, serialized in zip(messages, payload.get("messages", []), strict=False): + if isinstance(message, AIMessage): + reasoning = message.additional_kwargs.get("reasoning_content") + if isinstance(reasoning, str): + serialized["reasoning_content"] = reasoning + return payload + + def _create_chat_result( + self, + response: dict | openai.BaseModel, + generation_info: dict | None = None, + ) -> ChatResult: + result = super()._create_chat_result(response, generation_info) + data = response if isinstance(response, dict) else response.model_dump() + for generation, choice in zip(result.generations, data.get("choices", []), strict=False): + reasoning = choice.get("message", {}).get("reasoning_content") + if isinstance(reasoning, str): + generation.message.additional_kwargs["reasoning_content"] = reasoning + return result + + def _convert_chunk_to_generation_chunk( + self, + chunk: dict, + default_chunk_class: type, + base_generation_info: dict | None, + ) -> ChatGenerationChunk | None: + result = super()._convert_chunk_to_generation_chunk( + chunk, default_chunk_class, base_generation_info + ) + choices = chunk.get("choices", []) + if result is not None and choices: + reasoning = choices[0].get("delta", {}).get("reasoning_content") + if isinstance(reasoning, str): + result.message.additional_kwargs["reasoning_content"] = reasoning + return result diff --git a/src/skillspector/providers/minimax/model_registry.yaml b/src/skillspector/providers/minimax/model_registry.yaml new file mode 100644 index 000000000..ed87e07c2 --- /dev/null +++ b/src/skillspector/providers/minimax/model_registry.yaml @@ -0,0 +1,14 @@ +# Token-budget metadata for the MiniMax provider. +# +# Format: +# models: +# "": +# context_length: +# max_output_tokens: # optional when no output cap is published + +models: + "MiniMax-M3.1-Flash": + context_length: 1000000 + max_output_tokens: 524288 + "MiniMax-M3": + context_length: 1000000 diff --git a/src/skillspector/providers/minimax/provider.py b/src/skillspector/providers/minimax/provider.py new file mode 100644 index 000000000..8a8d4b2c2 --- /dev/null +++ b/src/skillspector/providers/minimax/provider.py @@ -0,0 +1,112 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""MiniMax provider with global and China regional endpoint selection. + +``MINIMAX_REGION`` accepts ``global_en`` (the default) or ``cn_zh``. +``MINIMAX_BASE_URL`` can override the selected regional endpoint. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +from langchain_core.language_models.chat_models import BaseChatModel + +from skillspector.providers import registry +from skillspector.providers.chat_models import ( + UnsupportedControlError, + create_openai_compatible_chat_model, + resolve_reasoning_effort, +) +from skillspector.providers.minimax.chat_model import MiniMaxChatOpenAI + +MINIMAX_GLOBAL_BASE_URL = "https://api.minimax.io/v1" +MINIMAX_CN_BASE_URL = "https://api.minimaxi.com/v1" +MINIMAX_BASE_URLS = { + "global_en": MINIMAX_GLOBAL_BASE_URL, + "cn_zh": MINIMAX_CN_BASE_URL, +} + +REGISTRY_PATH = str(Path(__file__).with_name("model_registry.yaml")) + + +def _resolve_base_url() -> str: + override = os.environ.get("MINIMAX_BASE_URL", "").strip() + if override: + return override + + region = os.environ.get("MINIMAX_REGION", "").strip().lower() or "global_en" + try: + return MINIMAX_BASE_URLS[region] + except KeyError as exc: + raise ValueError("MINIMAX_REGION must be 'global_en' or 'cn_zh'") from exc + + +class MiniMaxProvider: + """MiniMax credentials, regional routing, and bundled model metadata.""" + + DEFAULT_MODEL = "MiniMax-M3.1-Flash" + SLOT_DEFAULTS: dict[str, str] = {} + + def resolve_credentials(self) -> tuple[str, str | None] | None: + """Return the MiniMax API key and selected regional base URL.""" + api_key = os.environ.get("MINIMAX_API_KEY", "").strip() + if not api_key: + return None + return api_key, _resolve_base_url() + + def create_chat_model( + self, + model: str, + *, + max_tokens: int, + timeout: float | None = 120, + ) -> BaseChatModel | None: + """Create a chat model for the selected MiniMax endpoint.""" + effort = resolve_reasoning_effort() + allowed_efforts = ("low", "medium", "high", "xhigh", "max") + if model == "MiniMax-M3.1-Flash" and effort and effort not in allowed_efforts: + raise UnsupportedControlError( + f"SKILLSPECTOR_REASONING_EFFORT={effort!r} is not supported by {model}; " + f"use one of {', '.join(allowed_efforts)} or unset it" + ) + return create_openai_compatible_chat_model( + model=model, + credentials=self.resolve_credentials(), + max_tokens=max_tokens, + timeout=timeout, + chat_model_class=MiniMaxChatOpenAI, + ) + + def structured_output_method(self, model: str) -> str: + """Use the documented tool-calling API for scanner schemas.""" + return registry.lookup_structured_output_method(REGISTRY_PATH, model) or "function_calling" + + def get_context_length(self, model: str) -> int | None: + context_length = registry.lookup_context_length(REGISTRY_PATH, model) + if context_length is None and not os.environ.get("SKILLSPECTOR_MODEL_REGISTRY", "").strip(): + # Keep explicit legacy requests' budget without advertising them in the catalog. + return {"MiniMax-M2.7": 204_800}.get(model) + return context_length + + def get_max_output_tokens(self, model: str) -> int | None: + return registry.lookup_max_output_tokens(REGISTRY_PATH, model) + + def resolve_model(self, slot: str = "default") -> str: + """Resolve model from an environment override or the bundled default.""" + user_input = os.environ.get("SKILLSPECTOR_MODEL", "").strip() + return user_input or self.SLOT_DEFAULTS.get(slot, "") or self.DEFAULT_MODEL diff --git a/src/skillspector/yara_rules/agent_skills.yar b/src/skillspector/yara_rules/agent_skills.yar index 6aa11e5ae..bd9f0064f 100644 --- a/src/skillspector/yara_rules/agent_skills.yar +++ b/src/skillspector/yara_rules/agent_skills.yar @@ -27,7 +27,7 @@ rule agent_skill_credential_exfiltration_webhook $secret_env_js = /process\.env(\.|\[|\s|$)/ nocase $secret_dotenv_read = /open\s*\(\s*['"][^'"]*\.env['"]/ nocase $secret_ssh_key = /(\.ssh\/(id_rsa|id_ed25519)|authorized_keys)/ nocase - $secret_cloud_key = /(OPENAI_API_KEY|ANTHROPIC_API_KEY|NVIDIA_INFERENCE_KEY|AWS_SECRET_ACCESS_KEY|GITHUB_TOKEN|HF_TOKEN)/ nocase + $secret_cloud_key = /(OPENAI_API_KEY|ANTHROPIC_API_KEY|MINIMAX_API_KEY|NVIDIA_INFERENCE_KEY|AWS_SECRET_ACCESS_KEY|GITHUB_TOKEN|HF_TOKEN)/ nocase $send_requests = /(requests|httpx)\.(post|put)\s*\(/ nocase $send_fetch = /(fetch|axios\.post)\s*\(/ nocase @@ -138,4 +138,4 @@ rule agent_skill_destructive_autonomous_actions $autonomy_silent = /(silently|non-interactive|unattended)/ nocase condition: $destructive_rm_root or (any of ($destructive_*) and any of ($autonomy_*)) -} \ No newline at end of file +} diff --git a/tests/unit/test_minimax_provider.py b/tests/unit/test_minimax_provider.py new file mode 100644 index 000000000..bd13ad2c3 --- /dev/null +++ b/tests/unit/test_minimax_provider.py @@ -0,0 +1,357 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for the MiniMax provider.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import httpx +import pytest +import yaml +from langchain_core.messages import ToolMessage +from langchain_openai import ChatOpenAI +from pydantic import BaseModel + +import skillspector.providers as providers_module +from skillspector.inference_usage import provider_name +from skillspector.llm_provenance import capture_llm_provenance, sanitize_llm_provenance +from skillspector.llm_utils import bind_structured_output, get_chat_model +from skillspector.providers import get_metadata_provider, registry, resolve_provider_credentials +from skillspector.providers._agent_cli import _scrub_env +from skillspector.providers.chat_models import UnsupportedControlError +from skillspector.providers.minimax import ( + MINIMAX_CN_BASE_URL, + MINIMAX_GLOBAL_BASE_URL, + REGISTRY_PATH, + MiniMaxProvider, +) + + +@pytest.fixture +def mock_resolve_context_length(): + """Use MiniMax's local registry instead of the suite-wide context-size stub.""" + yield + + +@pytest.fixture(autouse=True) +def _clean_provider_env(monkeypatch: pytest.MonkeyPatch): + for key in ( + "MINIMAX_API_KEY", + "MINIMAX_BASE_URL", + "MINIMAX_REGION", + "SKILLSPECTOR_MODEL", + "SKILLSPECTOR_MODEL_REGISTRY", + "SKILLSPECTOR_PROVIDER", + "SKILLSPECTOR_REASONING_EFFORT", + "SKILLSPECTOR_TEMPERATURE", + "SKILLSPECTOR_SEED", + "SKILLSPECTOR_STRUCTURED_OUTPUT_METHOD", + ): + monkeypatch.delenv(key, raising=False) + providers_module._INJECTED_PROVIDER.set(None) + registry._load.cache_clear() + yield + providers_module._INJECTED_PROVIDER.set(None) + registry._load.cache_clear() + + +class TestMiniMaxProvider: + def test_returns_none_without_api_key(self) -> None: + assert MiniMaxProvider().resolve_credentials() is None + + def test_uses_global_endpoint_by_default(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + assert MiniMaxProvider().resolve_credentials() == ( + "test-key", + MINIMAX_GLOBAL_BASE_URL, + ) + + def test_selects_china_endpoint(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + monkeypatch.setenv("MINIMAX_REGION", "cn_zh") + assert MiniMaxProvider().resolve_credentials() == ( + "test-key", + MINIMAX_CN_BASE_URL, + ) + + def test_honors_base_url_override(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + monkeypatch.setenv("MINIMAX_BASE_URL", "https://minimax.example.com/v1") + assert MiniMaxProvider().resolve_credentials() == ( + "test-key", + "https://minimax.example.com/v1", + ) + + def test_rejects_unknown_region(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + monkeypatch.setenv("MINIMAX_REGION", "unknown") + with pytest.raises(ValueError, match="global_en.*cn_zh"): + MiniMaxProvider().resolve_credentials() + + def test_creates_chat_model(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + llm = MiniMaxProvider().create_chat_model("MiniMax-M2.7", max_tokens=123) + assert isinstance(llm, ChatOpenAI) + assert llm.model_name == "MiniMax-M2.7" + assert llm.max_tokens == 123 + assert str(llm.openai_api_base).rstrip("/") == MINIMAX_GLOBAL_BASE_URL + + def test_bundled_models_and_context_windows(self) -> None: + provider = MiniMaxProvider() + assert provider.resolve_model() == "MiniMax-M3.1-Flash" + assert list(yaml.safe_load(Path(REGISTRY_PATH).read_text())["models"]) == [ + "MiniMax-M3.1-Flash", + "MiniMax-M3", + ] + assert provider.get_context_length("MiniMax-M3.1-Flash") == 1_000_000 + assert provider.get_max_output_tokens("MiniMax-M3.1-Flash") == 524_288 + assert provider.get_context_length("MiniMax-M3") == 1_000_000 + assert provider.get_context_length("MiniMax-M2.7") == 204_800 + assert provider.get_max_output_tokens("MiniMax-M3") is None + + def test_selector_uses_minimax_provider(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "minimax") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + assert resolve_provider_credentials() == ("test-key", MINIMAX_GLOBAL_BASE_URL) + assert isinstance(get_metadata_provider(), MiniMaxProvider) + + def test_api_key_is_removed_from_cli_environment(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + assert "MINIMAX_API_KEY" not in _scrub_env() + + +@pytest.fixture +def requests(monkeypatch: pytest.MonkeyPatch) -> list[httpx.Request]: + """Capture real SDK HTTP requests without opening a network connection.""" + captured: list[httpx.Request] = [] + + def send(_client: httpx.Client, request: httpx.Request, **kwargs: object) -> httpx.Response: + captured.append(request) + body = json.loads(request.content) + message: dict[str, object] = { + "role": "assistant", + "content": "safe", + "reasoning_content": "Check permissions.", + } + if body.get("tools"): + message["tool_calls"] = [ + { + "id": "call_scan", + "type": "function", + "function": { + "name": body["tools"][0]["function"]["name"], + "arguments": '{"safe": true}', + }, + } + ] + if body.get("stream"): + tool_calls = message.get("tool_calls", []) + for index, tool_call in enumerate(tool_calls): + tool_call["index"] = index + chunk = { + "id": "test-stream", + "object": "chat.completion.chunk", + "created": 0, + "model": body["model"], + "choices": [{"index": 0, "delta": message, "finish_reason": None}], + } + return httpx.Response( + 200, + request=request, + headers={"content-type": "text/event-stream"}, + content=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n", + ) + return httpx.Response( + 200, + request=request, + json={ + "id": "test-completion", + "object": "chat.completion", + "created": 0, + "model": body["model"], + "choices": [{"index": 0, "message": message, "finish_reason": "stop"}], + }, + ) + + async def async_send( + client: httpx.AsyncClient, request: httpx.Request, **kwargs: object + ) -> httpx.Response: + return send(client, request, **kwargs) + + monkeypatch.setattr(httpx.Client, "send", send) + monkeypatch.setattr(httpx.AsyncClient, "send", async_send) + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "minimax") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + return captured + + +@pytest.mark.parametrize( + ("region", "override", "base_url"), + [ + ("global_en", None, MINIMAX_GLOBAL_BASE_URL), + ("cn_zh", None, MINIMAX_CN_BASE_URL), + ("cn_zh", "https://proxy.example/v1", "https://proxy.example/v1"), + ], +) +@pytest.mark.parametrize("model", [None, "MiniMax-M3", "MiniMax-M2.7", "custom-model"]) +def test_scanner_serializes_default_and_explicit_models( + monkeypatch: pytest.MonkeyPatch, + requests: list[httpx.Request], + region: str, + override: str | None, + base_url: str, + model: str | None, +) -> None: + monkeypatch.setenv("MINIMAX_REGION", region) + if override: + monkeypatch.setenv("MINIMAX_BASE_URL", override) + if model: + monkeypatch.setenv("SKILLSPECTOR_MODEL", model) + llm = get_chat_model() + assert llm.invoke("Inspect this skill").content == "safe" + request = requests[0] + body = json.loads(request.content) + assert str(request.url) == base_url + "/chat/completions" + assert request.headers["authorization"] == "Bearer test-key" + assert body["model"] == (model or "MiniMax-M3.1-Flash") + assert body["max_completion_tokens"] > 0 + if model == "MiniMax-M2.7": + assert body["max_completion_tokens"] == 51_200 + assert not {"service_tier", "reasoning_split", "thinking", "reasoning_effort"} & body.keys() + + +def test_saved_registry_budget_and_schema_remain_effective( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path, requests: list[httpx.Request] +) -> None: + saved_registry = tmp_path / "models.yaml" + saved_registry.write_text( + "models:\n MiniMax-M2.7:\n context_length: 80000\n" + " max_output_tokens: 8192\n structured_output: json_mode\n" + ) + monkeypatch.setenv("SKILLSPECTOR_MODEL_REGISTRY", str(saved_registry)) + monkeypatch.setenv("SKILLSPECTOR_MODEL", "MiniMax-M2.7") + get_chat_model().invoke("Inspect this skill") + assert json.loads(requests[0].content)["max_completion_tokens"] == 8192 + assert MiniMaxProvider().structured_output_method("MiniMax-M2.7") == "json_mode" + + +@pytest.mark.parametrize("effort", ["low", "medium", "high", "xhigh", "max"]) +def test_flash_serializes_supported_effort( + monkeypatch: pytest.MonkeyPatch, requests: list[httpx.Request], effort: str +) -> None: + monkeypatch.setenv("SKILLSPECTOR_REASONING_EFFORT", effort) + get_chat_model().invoke("Inspect this skill") + assert json.loads(requests[0].content)["reasoning_effort"] == effort + + +@pytest.mark.parametrize("effort", ["none", "minimal", "invalid"]) +def test_flash_rejects_invalid_effort_before_http( + monkeypatch: pytest.MonkeyPatch, requests: list[httpx.Request], effort: str +) -> None: + monkeypatch.setenv("SKILLSPECTOR_REASONING_EFFORT", effort) + with pytest.raises(UnsupportedControlError, match="MiniMax-M3.1-Flash"): + get_chat_model() + assert requests == [] + + +@pytest.mark.parametrize("model", ["MiniMax-M3", "MiniMax-M2.7", "custom-model"]) +def test_explicit_models_keep_existing_effort_passthrough( + monkeypatch: pytest.MonkeyPatch, requests: list[httpx.Request], model: str +) -> None: + monkeypatch.setenv("SKILLSPECTOR_REASONING_EFFORT", "none") + get_chat_model(model).invoke("Inspect this skill") + assert json.loads(requests[0].content)["reasoning_effort"] == "none" + + +def test_scanner_uses_tool_schema_and_preserves_multimodal_input( + requests: list[httpx.Request], +) -> None: + class ScanResult(BaseModel): + """Result of inspecting a skill.""" + + safe: bool + + content = [ + {"type": "text", "text": "Inspect this skill"}, + {"type": "image_url", "image_url": {"url": "https://example.com/image.png"}}, + {"type": "video_url", "video_url": {"url": "https://example.com/video.mp4"}}, + ] + llm = get_chat_model() + structured = bind_structured_output(llm, ScanResult, "MiniMax-M3.1-Flash") + assert structured.invoke([{"role": "user", "content": content}]).safe is True + body = json.loads(requests[0].content) + assert "response_format" not in body + assert body["tools"][0]["function"]["name"] == "ScanResult" + assert body["messages"][0]["content"] == content + + +@pytest.mark.asyncio +@pytest.mark.parametrize("stream", [False, True]) +@pytest.mark.parametrize("asynchronous", [False, True]) +async def test_reasoning_and_tool_history_round_trip( + requests: list[httpx.Request], stream: bool, asynchronous: bool +) -> None: + class ScanResult(BaseModel): + """Result of inspecting a skill.""" + + safe: bool + + llm = get_chat_model().bind_tools([ScanResult]) + if asynchronous: + if stream: + chunks = [chunk async for chunk in llm.astream("Inspect this skill")] + reply = sum(chunks[1:], chunks[0]) + else: + reply = await llm.ainvoke("Inspect this skill") + elif stream: + chunks = list(llm.stream("Inspect this skill")) + reply = sum(chunks[1:], chunks[0]) + else: + reply = llm.invoke("Inspect this skill") + assert reply.additional_kwargs["reasoning_content"] == "Check permissions." + assert reply.tool_calls[0]["id"] == "call_scan" + llm.invoke([reply, ToolMessage(content="checked", tool_call_id="call_scan")]) + messages = json.loads(requests[-1].content)["messages"] + assert messages[0]["reasoning_content"] == "Check permissions." + assert messages[0]["tool_calls"][0]["id"] == "call_scan" + assert messages[1]["tool_call_id"] == "call_scan" + + +def test_minimax_provenance_tracks_forwarded_controls(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("SKILLSPECTOR_PROVIDER", "minimax") + monkeypatch.setenv("MINIMAX_API_KEY", "test-key") + controls = {"temperature": 0.5, "seed": 7, "reasoning_effort": "high"} + for name, value in controls.items(): + monkeypatch.setenv(f"SKILLSPECTOR_{name.upper()}", str(value)) + assert provider_name(MiniMaxProvider()) == "minimax" + result = sanitize_llm_provenance( + capture_llm_provenance({}), + use_llm=True, + inference_usage=[ + { + "provider": "minimax", + "usage_source": "provider_response", + "requested_controls": controls, + "forwarded_controls": controls, + } + ], + ) + assert result["provider"]["resolved_adapter"] == "minimax" + for name, value in controls.items(): + assert result["sampling"][name]["adapter_support"] is True + assert result["sampling"][name]["forwarded_to_client"] == value