diff --git a/docs/static/openapi/cortex.json b/docs/static/openapi/cortex.json index 1378ade12..2ede0af0d 100644 --- a/docs/static/openapi/cortex.json +++ b/docs/static/openapi/cortex.json @@ -642,7 +642,8 @@ "example": { "model": "model-id", "modelPath": "/path/to/gguf", - "name": "model display name" + "name": "model display name", + "option": "symlink" } } } @@ -3187,6 +3188,11 @@ "name": { "type": "string", "description": "The display name of the model." + }, + "option": { + "type": "string", + "description": "Import options such as symlink or copy.", + "enum": ["symlink", "copy"] } }, "required": ["model", "modelPath"] diff --git a/engine/controllers/models.cc b/engine/controllers/models.cc index 939f63f31..544b3588c 100644 --- a/engine/controllers/models.cc +++ b/engine/controllers/models.cc @@ -1,5 +1,6 @@ #include "database/models.h" #include +#include #include #include "config/gguf_parser.h" #include "config/yaml_config.h" @@ -320,6 +321,7 @@ void Models::ImportModel( auto modelHandle = (*(req->getJsonObject())).get("model", "").asString(); auto modelPath = (*(req->getJsonObject())).get("modelPath", "").asString(); auto modelName = (*(req->getJsonObject())).get("name", "").asString(); + auto option = (*(req->getJsonObject())).get("option", "symlink").asString(); config::GGUFHandler gguf_handler; config::YamlHandler yaml_handler; cortex::db::Models modellist_utils_obj; @@ -339,7 +341,19 @@ void Models::ImportModel( std::filesystem::path(model_yaml_path).parent_path()); gguf_handler.Parse(modelPath); config::ModelConfig model_config = gguf_handler.GetModelConfig(); - model_config.files.push_back(modelPath); + // There are 2 options: symlink and copy + if (option == "copy") { + // Copy GGUF file to the destination path + std::filesystem::path file_path = + std::filesystem::path(model_yaml_path).parent_path() / + std::filesystem::path(modelPath).filename(); + std::filesystem::copy_file( + modelPath, file_path, + std::filesystem::copy_options::update_existing); + model_config.files.push_back(file_path.string()); + } else { + model_config.files.push_back(modelPath); + } model_config.model = modelHandle; model_config.name = modelName.empty() ? model_config.name : modelName; yaml_handler.UpdateModelConfig(model_config); diff --git a/engine/e2e-test/test_api_model_import.py b/engine/e2e-test/test_api_model_import.py index 3f8a82a0d..74481594c 100644 --- a/engine/e2e-test/test_api_model_import.py +++ b/engine/e2e-test/test_api_model_import.py @@ -29,6 +29,21 @@ def test_model_import_with_name_should_be_success(self): response = requests.post("http://localhost:3928/models/import", json=body_json) assert response.status_code == 200 + @pytest.mark.skipif(True, reason="Expensive test. Only test when you have local gguf file.") + def test_model_import_with_name_should_be_success(self): + body_json = {'model': 'testing-model', + 'modelPath': '/path/to/local/gguf', + 'name': 'test_model', + 'option': 'copy'} + response = requests.post("http://localhost:3928/models/import", json=body_json) + assert response.status_code == 200 + # Test imported path + response = requests.get("http://localhost:3928/models/testing-model") + assert response.status_code == 200 + # Since this is a dynamic test - require actual file path + # it's not safe to assert with the gguf file name + assert response.json()['files'][0] != '/path/to/local/gguf' + def test_model_import_with_invalid_path_should_fail(self): body_json = {'model': 'tinyllama:gguf', 'modelPath': '/invalid/path/to/gguf'}