File size: 17,034 Bytes
1393b01
796d506
0585716
 
796d506
26a1157
 
7295302
ad03828
796d506
64e99f5
796d506
e8ac14f
 
29e570d
e8ac14f
796d506
7295302
211a715
796d506
26a1157
9b55cea
 
26a1157
796d506
 
7295302
c1d0430
796d506
7295302
796d506
 
 
 
 
29e570d
796d506
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c262148
 
 
796d506
 
922a193
796d506
29e570d
 
 
 
 
 
 
e8ac14f
29e570d
e8ac14f
29e570d
 
 
e8ac14f
 
29e570d
 
e8ac14f
 
 
 
 
 
29e570d
e8ac14f
29e570d
 
 
 
e8ac14f
29e570d
 
e8ac14f
 
29e570d
 
 
 
e8ac14f
29e570d
 
 
 
 
 
 
 
 
 
 
 
e8ac14f
29e570d
 
e8ac14f
 
 
 
1393b01
29e570d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
796d506
e8ac14f
0585716
 
796d506
0585716
 
796d506
211a715
ad03828
0585716
 
796d506
e8ac14f
0585716
e8ac14f
 
29e570d
e8ac14f
 
 
 
 
 
 
 
1393b01
29e570d
e8ac14f
29e570d
 
 
 
 
 
b323e3d
5888100
796d506
64e99f5
 
 
6188097
e4c8ce8
6188097
0585716
 
 
 
 
 
 
 
 
 
 
 
 
 
211a715
8be82f3
26a1157
8be82f3
e7f2f83
 
796d506
205190d
0585716
29e570d
 
 
 
 
b323e3d
 
29e570d
 
 
e8ac14f
29e570d
 
 
 
 
 
 
 
 
 
 
 
 
e8ac14f
29e570d
 
 
e8ac14f
 
 
29e570d
 
 
 
e8ac14f
29e570d
 
 
0585716
 
 
29e570d
0585716
b8352d5
73a53d1
9b55cea
 
 
 
e8ac14f
9b55cea
 
 
 
 
 
e8ac14f
9b55cea
 
796d506
ad03828
 
9b55cea
29e570d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
ad03828
29e570d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
e8ac14f
29e570d
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
import os
import pathlib
import random
import string
import tempfile
import time
from concurrent.futures import ThreadPoolExecutor
from typing import Iterable, List

import gradio as gr
import huggingface_hub
import torch
import base64
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives import padding as sym_padding # Use symmetric padding
from cryptography.hazmat.backends import default_backend
import yaml
from gradio_logsview.logsview import Log, LogsView, LogsViewRunner
from mergekit.config import MergeConfiguration

from clean_community_org import garbage_collect_empty_models
from apscheduler.schedulers.background import BackgroundScheduler
from datetime import datetime, timezone

has_gpu = torch.cuda.is_available()

cli = "mergekit-yaml config.yaml merge --copy-tokenizer" + (
    " --cuda --low-cpu-memory --allow-crimes" if has_gpu else " --allow-crimes --out-shard-size 1B --lazy-unpickle"
)

MARKDOWN_DESCRIPTION = """
# mergekit-gui

The fastest way to perform a model merge πŸ”₯

Specify a YAML configuration file (see examples below) and a HF token and this app will perform the merge and upload the merged model to your user profile. Includes encryption for the `mergekit_config.yml` and a tool to decrypt it.
"""

MARKDOWN_ARTICLE = """
___

## Merge Configuration

[Mergekit](https://github.com/arcee-ai/mergekit) configurations are YAML documents specifying the operations to perform in order to produce your merged model.
Below are the primary elements of a configuration file:

- `merge_method`: Specifies the method to use for merging models. See [Merge Methods](https://github.com/arcee-ai/mergekit#merge-methods) for a list.
- `slices`: Defines slices of layers from different models to be used. This field is mutually exclusive with `models`.
- `models`: Defines entire models to be used for merging. This field is mutually exclusive with `slices`.
- `base_model`: Specifies the base model used in some merging methods.
- `parameters`: Holds various parameters such as weights and densities, which can also be specified at different levels of the configuration.
- `dtype`: Specifies the data type used for the merging operation.
- `tokenizer_source`: Determines how to construct a tokenizer for the merged model.

## Merge Methods

A quick overview of the currently supported merge methods:

| Method                                                                                       | `merge_method` value | Multi-Model | Uses base model |
| -------------------------------------------------------------------------------------------- | -------------------- | ----------- | --------------- |
| Linear ([Model Soups](https://arxiv.org/abs/2203.05482))                                     | `linear`             | βœ…          | ❌              |
| SLERP                                                                                        | `slerp`              | ❌          | βœ…              |
| [Task Arithmetic](https://arxiv.org/abs/2212.04089)                                          | `task_arithmetic`    | βœ…          | βœ…              |
| [TIES](https://arxiv.org/abs/2306.01708)                                                     | `ties`               | βœ…          | βœ…              |
| [DARE](https://arxiv.org/abs/2311.03099) [TIES](https://arxiv.org/abs/2306.01708)            | `dare_ties`          | βœ…          | βœ…              |
| [DARE](https://arxiv.org/abs/2311.03099) [Task Arithmetic](https://arxiv.org/abs/2212.04089) | `dare_linear`        | βœ…          | βœ…              |
| Passthrough                                                                                  | `passthrough`        | ❌          | ❌              |
| [Model Stock](https://arxiv.org/abs/2403.19522)                                              | `model_stock`        | βœ…          | βœ…              |

```

This Space is heavily inspired by LazyMergeKit by Maxime Labonne (see [Colab](https://colab.research.google.com/drive/1obulZ1ROXHjYLn6PPZJwRR6GzgQogxxb)).
"""

examples = [[str(f)] for f in pathlib.Path("examples").glob("*.yaml")]

def _prepare_key(key: str) -> bytes:
    """Pads or truncates the key to 32 bytes (256 bits) for AES."""
    key_bytes = key.encode('utf-8')
    if len(key_bytes) < 32:
        return key_bytes + b'\0' * (32 - len(key_bytes))
    else:
        return key_bytes[:32]

def encrypt_file(file_path, key: str) -> bool:
    """
    Encrypt the contents of a file using AES-256-CBC encryption with the provided key.
    The output is Base64 encoded.

    Args:
        file_path: Path to the file to encrypt (pathlib.Path or string)
        key: Encryption key string.

    Returns:
        bool: True if encryption was successful, False otherwise
    """
    try:
        file_path = pathlib.Path(file_path)
        if not file_path.exists():
            print(f"Encryption error: File not found at {file_path}")
            return False

        key_bytes = _prepare_key(key)

        # Generate a random IV (Initialization Vector) - 16 bytes for AES
        iv = os.urandom(16)

        # Create an AES cipher instance with CBC mode
        cipher = Cipher(algorithms.AES(key_bytes), modes.CBC(iv), backend=default_backend())
        encryptor = cipher.encryptor()

        # Use PKCS7 padding
        padder = sym_padding.PKCS7(algorithms.AES.block_size).padder()

        with open(file_path, 'rb') as f:
            plaintext = f.read()

        # Pad the data
        padded_data = padder.update(plaintext) + padder.finalize()

        # Encrypt the padded data
        ciphertext = encryptor.update(padded_data) + encryptor.finalize()

        # Prepend the IV to the ciphertext and base64 encode the result
        encrypted_data_with_iv = base64.b64encode(iv + ciphertext)

        # Write the base64 encoded encrypted data back to the file
        with open(file_path, 'wb') as f:
            f.write(encrypted_data_with_iv)

        return True
    except Exception as e:
        print(f"Encryption error: {e}")
        return False

def decrypt_file_content(file_input, key: str) -> str:
    """
    Decrypts the content of an uploaded file using AES-256-CBC and returns the result.
    Assumes the file content is Base64 encoded IV + ciphertext.

    Args:
        file_input: Gradio File component output (temporary file object).
        key: Decryption key string.

    Returns:
        str: Decrypted content as a UTF-8 string, or an error message.
    """
    if file_input is None:
        return "Error: No file provided for decryption."
    if not key:
        return "Error: Decryption key cannot be empty."

    try:
        file_path = file_input.name # Get the temporary file path from Gradio
        key_bytes = _prepare_key(key)

        with open(file_path, 'rb') as f:
            base64_encoded_data = f.read()

        # Decode from Base64
        encrypted_data_with_iv = base64.b64decode(base64_encoded_data)

        # Extract the IV (first 16 bytes)
        iv = encrypted_data_with_iv[:16]
        # Extract the ciphertext (the rest)
        ciphertext = encrypted_data_with_iv[16:]

        # Create an AES cipher instance with CBC mode for decryption
        cipher = Cipher(algorithms.AES(key_bytes), modes.CBC(iv), backend=default_backend())
        decryptor = cipher.decryptor()

        # Decrypt the data
        padded_plaintext = decryptor.update(ciphertext) + decryptor.finalize()

        # Unpad the data using PKCS7
        unpadder = sym_padding.PKCS7(algorithms.AES.block_size).unpadder()
        plaintext = unpadder.update(padded_plaintext) + unpadder.finalize()

        # Decode the plaintext from bytes to string (assuming UTF-8)
        return plaintext.decode('utf-8')

    except (ValueError, TypeError) as e:
         # Catches Base64 decoding errors, incorrect key type errors
        return f"Decryption Error: Invalid input data or key format. ({e})"
    except Exception as e:
        # Catches padding errors (often due to wrong key), or other crypto issues
        print(f"Decryption error details: {e}")
        return f"Decryption Failed: Likely incorrect key or corrupted file. Error: {type(e).__name__}"


def merge(yaml_config: str, hf_token: str, repo_name: str, cipher_key: str) -> Iterable[List[Log]]:
    runner = LogsViewRunner()

    if not yaml_config:
        yield runner.log("Empty yaml, pick an example below", level="ERROR")
        return
    try:
        merge_config = MergeConfiguration.model_validate(yaml.safe_load(yaml_config))
    except Exception as e:
        yield runner.log(f"Invalid yaml {e}", level="ERROR")
        return

    # Check if HF token is provided
    if not hf_token:
        yield runner.log("No HF token provided. A valid token is required for uploading.", level="ERROR")
        return

    # Validate that the token works by trying to get user info
    try:
        api = huggingface_hub.HfApi(token=hf_token)
        me = api.whoami()
        yield runner.log(f"Authenticated as: {me['name']} ({me.get('fullname', '')})")
    except Exception as e:
        yield runner.log(f"Invalid HF token: {e}", level="ERROR")
        return

    # Use default key if none provided, but log a warning
    if not cipher_key:
        cipher_key = "default_insecure_key" # Make default explicitely insecure sounding
        yield runner.log("No cipher key provided. Using a default, insecure key. Please provide your own key for security.", level="WARNING")
    elif cipher_key == "Default": # Check against the placeholder value
         cipher_key = "default_insecure_key" # Treat placeholder as no key provided
         yield runner.log("Default placeholder key detected. Using an insecure key. Please provide your own key.", level="WARNING")


    with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as tmpdirname:
        tmpdir = pathlib.Path(tmpdirname)
        merged_path = tmpdir / "merged"
        merged_path.mkdir(parents=True, exist_ok=True)
        config_path = merged_path / "config.yaml"
        config_path.write_text(yaml_config)
        yield runner.log(f"Merge configuration saved in {config_path}")

        if not repo_name:
            yield runner.log("No repo name provided. Generating a random one.")
            repo_name = f"mergekit-{merge_config.merge_method}"
            # Make repo_name "unique" (no need to be extra careful on uniqueness)
            repo_name += "-" + "".join(random.choices(string.ascii_lowercase, k=7))
            repo_name = repo_name.replace("/", "-").strip("-")

        try:
            yield runner.log(f"Creating repo {repo_name}")
            repo_url = api.create_repo(repo_name, exist_ok=True)
            yield runner.log(f"Repo created: {repo_url}")
        except Exception as e:
            yield runner.log(f"Error creating repo {e}", level="ERROR")
            return

        # Set tmp HF_HOME to avoid filling up disk Space
        tmp_env = os.environ.copy()  # taken from https://stackoverflow.com/a/4453495
        tmp_env["HF_HOME"] = f"{tmpdirname}/.cache"
        full_cli = cli + f" --lora-merge-cache {tmpdirname}/.lora_cache"
        yield from runner.run_command(full_cli.split(), cwd=merged_path, env=tmp_env)

        if runner.exit_code != 0:
            yield runner.log("Merge failed. Deleting repo as no model is uploaded.", level="ERROR")
            try:
                api.delete_repo(repo_url.repo_id)
                yield runner.log(f"Repo {repo_url.repo_id} deleted.")
            except Exception as delete_e:
                 yield runner.log(f"Failed to delete repo {repo_url.repo_id}: {delete_e}", level="WARNING")
            return

        yield runner.log("Model merged successfully. Preparing for upload.")

        # ---- Encryption Step ----
        merge_dir = merged_path / "merge"
        config_yml_path = merge_dir / "mergekit_config.yml"

        if config_yml_path.exists():
            yield runner.log(f"Found {config_yml_path.name}. Encrypting...")
            if encrypt_file(config_yml_path, cipher_key):
                yield runner.log(f"Successfully encrypted {config_yml_path.name} with provided key.")
            else:
                yield runner.log(f"Failed to encrypt {config_yml_path.name}. Uploading unencrypted.", level="ERROR")
        else:
            yield runner.log(f"{config_yml_path.name} not found in merge output, nothing to encrypt.", level="INFO")
        # ---- End Encryption Step ----

        # Delete Readme.md if it exists (case-insensitive check) before upload
        readme_deleted = False
        try:
            for file in merge_dir.glob("*"):
                if file.name.lower() == "readme.md":
                    file.unlink()
                    readme_deleted = True
                    yield runner.log(f"Deleted {file.name} file before upload")
                    break # Assume only one readme
        except Exception as e:
            yield runner.log(f"Error deleting Readme.md: {e}", level="WARNING")

        if not readme_deleted:
            yield runner.log("No Readme.md file found to delete.", level="INFO")

        yield runner.log("Uploading merged model files to HF.")
        yield from runner.run_python(
            api.upload_folder,
            repo_id=repo_url.repo_id,
            folder_path=merge_dir, # Upload from the 'merge' subdirectory
        )
        yield runner.log(f"Model successfully uploaded to HF: {repo_url.repo_id}")

# Run garbage collection every hour to keep the community org clean.
# Empty models might exists if the merge fails abruptly (e.g. if user leaves the Space).
def _garbage_remover():
    try:
        garbage_collect_empty_models(token=os.getenv("COMMUNITY_HF_TOKEN"))
    except Exception as e:
        print("Error running garbage collection", e)

scheduler = BackgroundScheduler()
garbage_remover_job = scheduler.add_job(_garbage_remover, "interval", seconds=3600)
scheduler.start()
next_run_time_utc = garbage_remover_job.next_run_time.astimezone(timezone.utc)

NEXT_RESTART = f"Next Restart: {next_run_time_utc.strftime('%Y-%m-%d %H:%M:%S')} (UTC)"

with gr.Blocks() as demo:
    gr.Markdown(MARKDOWN_DESCRIPTION)
    gr.Markdown(NEXT_RESTART)

    with gr.Tabs():
        with gr.TabItem("Merge Model"):
            with gr.Row():
                filename = gr.Textbox(visible=False, label="filename")
                config = gr.Code(language="yaml", lines=10, label="config.yaml")
                with gr.Column():
                    token = gr.Textbox(
                        lines=1,
                        label="HF Write Token",
                        info="https://hf.co/settings/token",
                        type="password",
                        placeholder="Required for model upload.",
                    )
                    repo_name = gr.Textbox(
                        lines=1,
                        label="Repo name",
                        placeholder="Optional. Will create a random name if empty.",
                    )
                    cipher_key = gr.Textbox(
                        lines=1,
                        label="Encryption Key",
                        type="password",
                        info="Key used to encrypt the generated mergekit_config.yml file before upload. Leave blank or 'Default' for no encryption (or insecure default).",
                        placeholder="Enter your secret key here",
                        value="Default" # Set a default placeholder
                    )
            button = gr.Button("Merge and Upload", variant="primary")
            logs = LogsView(label="Merge Progress / Terminal output")
            gr.Examples(
                examples,
                fn=lambda s: (s,),
                run_on_click=True,
                label="Merge Examples",
                inputs=[filename],
                outputs=[config],
            )
            gr.Markdown(MARKDOWN_ARTICLE)

            button.click(fn=merge, inputs=[config, token, repo_name, cipher_key], outputs=[logs])

        with gr.TabItem("Decrypt Configuration"):
            gr.Markdown("Upload an encrypted `mergekit_config.yml` file and provide the key to decrypt it.")
            with gr.Row():
                decrypt_file_input = gr.File(label="Upload Encrypted mergekit_config.yml")
                decrypt_key_input = gr.Textbox(
                    lines=1,
                    label="Decryption Key",
                    type="password",
                    placeholder="Enter the key used for encryption",
                )
            decrypt_button = gr.Button("Decrypt File", variant="secondary")
            decrypted_output = gr.Code(language="yaml", label="Decrypted Configuration", lines=15, interactive=False)

            decrypt_button.click(
                fn=decrypt_file_content,
                inputs=[decrypt_file_input, decrypt_key_input],
                outputs=[decrypted_output]
            )


demo.queue(default_concurrency_limit=1).launch()