simulst 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ """Install OpenAI chat assets into a merged SimulST checkpoint.
2
+
3
+ The template adds an audio-aware branch for ``/v1/chat/completions`` while
4
+ preserving the checkpoint's original template for plain-string callers. Each
5
+ OpenAI audio part becomes one placeholder token; the multimodal processor later
6
+ expands it to the number of speech tokens required by that chunk.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ from functools import lru_cache
14
+ from pathlib import Path
15
+
16
+ BOS = "<|hy_begin▁of▁sentence|>"
17
+ REASONING_PREFIX = "<|reasoning_mode|>reasoning_effort:no_think"
18
+ USER = "<|hy_User|>"
19
+ ASSISTANT = "<|hy_Assistant|>"
20
+ EMPTY_THINK = "<think></think>"
21
+ TURN_END = "<|hy_EOT|>"
22
+ AUDIO_BEGIN = "<|hy_place▁holder▁no▁6|>"
23
+ AUDIO_TOKEN = "<|hy_place▁holder▁no▁5|>"
24
+ AUDIO_END = "<|hy_place▁holder▁no▁8|>"
25
+ AUDIO_CHUNK_PLACEHOLDER = f"{AUDIO_BEGIN}{AUDIO_TOKEN}{AUDIO_END}"
26
+ BILINGUAL_CONTROL_TOKEN_ID = 120808
27
+ BILINGUAL_CONTROL_TOKEN = "<|hy_place▁holder▁no▁790|>"
28
+
29
+ TEMPLATE_FILE = "chat_template.jinja"
30
+ GENERATION_CONFIG_FILE = "generation_config.json"
31
+ LEGACY_MARKER = "{#- simulst-mm openai chat branch v1 #}"
32
+ MARKER = "{#- simulst-mm openai chat branch v2 #}"
33
+ DEFAULT_MAX_NEW_TOKENS = 64
34
+ BRANCH_END = "\n{%- endif -%}\n"
35
+
36
+
37
+ def _simulst_branch(marker: str, generation_prompt: str) -> str:
38
+ return (
39
+ marker + "\n"
40
+ "{%- if messages and messages[0]['content'] is not string -%}\n"
41
+ f"{{{{- '{BOS}{REASONING_PREFIX}' -}}}}\n"
42
+ "{%- for message in messages -%}\n"
43
+ "{%- if message['role'] == 'user' -%}\n"
44
+ f"{{{{- '{USER}' -}}}}\n"
45
+ "{%- for part in message['content'] -%}\n"
46
+ "{%- if part['type'] == 'text' -%}{{- part['text'] -}}\n"
47
+ f"{{%- elif part['type'] in ['audio', 'input_audio'] -%}}{{{{- '{AUDIO_CHUNK_PLACEHOLDER}' -}}}}\n"
48
+ "{%- endif -%}\n"
49
+ "{%- endfor -%}\n"
50
+ "{%- elif message['role'] == 'assistant' -%}\n"
51
+ f"{{{{- '{ASSISTANT}{EMPTY_THINK}' -}}}}\n"
52
+ "{%- if message['content'] is string -%}\n"
53
+ "{{- message['content'] -}}\n"
54
+ "{%- else -%}\n"
55
+ "{%- for part in message['content'] -%}\n"
56
+ "{%- if part['type'] == 'text' -%}{{- part['text'] -}}{%- endif -%}\n"
57
+ "{%- endfor -%}\n"
58
+ "{%- endif -%}\n"
59
+ f"{{{{- '{TURN_END}' -}}}}\n"
60
+ "{%- endif -%}\n"
61
+ "{%- endfor -%}\n"
62
+ + generation_prompt
63
+ + "{%- else -%}\n"
64
+ )
65
+
66
+
67
+ LEGACY_SIMULST_BRANCH = _simulst_branch(
68
+ LEGACY_MARKER,
69
+ f"{{%- if add_generation_prompt -%}}{{{{- '{ASSISTANT}{EMPTY_THINK}' -}}}}{{%- endif -%}}\n",
70
+ )
71
+ SIMULST_BRANCH = _simulst_branch(
72
+ MARKER,
73
+ f"{{%- if add_generation_prompt -%}}{{{{- '{ASSISTANT}{EMPTY_THINK}' -}}}}\n"
74
+ "{%- if simulst_output_mode|default('translation') == 'bilingual' -%}\n"
75
+ f"{{{{- '{BILINGUAL_CONTROL_TOKEN}' -}}}}\n"
76
+ "{%- endif -%}{%- endif -%}\n",
77
+ )
78
+
79
+
80
+ def _unwrap_branch(template: str, branch: str) -> str:
81
+ if not template.startswith(branch) or not template.endswith(BRANCH_END):
82
+ raise ValueError("existing SimulST chat template wrapper is malformed")
83
+ return template[len(branch) : -len(BRANCH_END)]
84
+
85
+
86
+ def wrap_chat_template(original: str) -> str:
87
+ """Prefix ``original`` with the current SimulST branch; migrate v1."""
88
+ if original.startswith(MARKER):
89
+ _unwrap_branch(original, SIMULST_BRANCH)
90
+ return original
91
+ if original.startswith(LEGACY_MARKER):
92
+ original = _unwrap_branch(original, LEGACY_SIMULST_BRANCH)
93
+ return SIMULST_BRANCH + original + BRANCH_END
94
+
95
+
96
+ def unwrap_chat_template(template: str) -> str:
97
+ """Recover the original template from a current or legacy wrapper."""
98
+ if template.startswith(MARKER):
99
+ return _unwrap_branch(template, SIMULST_BRANCH)
100
+ if template.startswith(LEGACY_MARKER):
101
+ return _unwrap_branch(template, LEGACY_SIMULST_BRANCH)
102
+ return template
103
+
104
+
105
+ @lru_cache(maxsize=8)
106
+ def assets_installed(model_dir: str) -> bool:
107
+ """Return whether the checkpoint contains the SimulST OpenAI branch."""
108
+ try:
109
+ head = (Path(model_dir) / TEMPLATE_FILE).read_text(encoding="utf-8")[
110
+ : len(MARKER)
111
+ ]
112
+ except OSError:
113
+ return False
114
+ return head == MARKER
115
+
116
+
117
+ def missing_assets_error(model_dir: str) -> str:
118
+ return (
119
+ f"{model_dir} has no SimulST OpenAI chat template. Install the model "
120
+ "assets, then restart vLLM:\n"
121
+ f" python -m simulst_mm.chat_template --model {model_dir}\n"
122
+ "Checkpoints produced by simulst_mm.export.merge_export include these "
123
+ "assets by default."
124
+ )
125
+
126
+
127
+ def generation_config(
128
+ eos_token_id: int, turn_end_token_id: int, bos_token_id: int
129
+ ) -> dict:
130
+ """Return greedy defaults that stop on EOS or the SimulST turn marker."""
131
+ return {
132
+ "bos_token_id": bos_token_id,
133
+ "eos_token_id": [eos_token_id, turn_end_token_id],
134
+ "temperature": 0.0,
135
+ "max_new_tokens": DEFAULT_MAX_NEW_TOKENS,
136
+ }
137
+
138
+
139
+ def _replace(path: Path, text: str) -> None:
140
+ """Replace through a fresh inode so a hard-linked source stays unchanged."""
141
+ tmp = path.with_name(path.name + ".tmp")
142
+ tmp.write_text(text, encoding="utf-8")
143
+ tmp.replace(path)
144
+
145
+
146
+ def write_openai_assets(model_dir: Path) -> list[str]:
147
+ """Install the wrapped template and generation config into ``model_dir``."""
148
+ model_dir = Path(model_dir)
149
+ config = json.loads((model_dir / "config.json").read_text(encoding="utf-8"))
150
+ turn_end = config.get("turn_end_token_id")
151
+ if turn_end is None:
152
+ raise ValueError(
153
+ f"{model_dir}/config.json has no turn_end_token_id; run merge_export"
154
+ )
155
+
156
+ tokenizer_config = json.loads(
157
+ (model_dir / "tokenizer_config.json").read_text(encoding="utf-8")
158
+ )
159
+ added = tokenizer_config.get("added_tokens_decoder", {})
160
+ ids = {entry["content"]: int(token_id) for token_id, entry in added.items()}
161
+ bilingual_entry = added.get(str(BILINGUAL_CONTROL_TOKEN_ID))
162
+ if bilingual_entry is None:
163
+ raise ValueError(
164
+ f"tokenizer has no bilingual control token {BILINGUAL_CONTROL_TOKEN_ID}"
165
+ )
166
+ if bilingual_entry.get("content") != BILINGUAL_CONTROL_TOKEN:
167
+ raise ValueError(
168
+ f"token {BILINGUAL_CONTROL_TOKEN_ID} is not {BILINGUAL_CONTROL_TOKEN!r}"
169
+ )
170
+ if ids.get(BILINGUAL_CONTROL_TOKEN) != BILINGUAL_CONTROL_TOKEN_ID:
171
+ raise ValueError(
172
+ f"{BILINGUAL_CONTROL_TOKEN!r} does not map to token ID "
173
+ f"{BILINGUAL_CONTROL_TOKEN_ID}"
174
+ )
175
+
176
+ required = (BOS, USER, ASSISTANT, TURN_END, AUDIO_BEGIN, AUDIO_TOKEN, AUDIO_END)
177
+ missing = [token for token in required if token not in ids]
178
+ if missing:
179
+ raise ValueError(f"tokenizer is missing special tokens {missing}")
180
+ if ids[TURN_END] != int(turn_end):
181
+ raise ValueError("turn_end_token_id in config.json does not match the tokenizer")
182
+ if ids[AUDIO_TOKEN] != int(config.get("audio_token_id", -1)):
183
+ raise ValueError("audio_token_id in config.json does not match the tokenizer")
184
+
185
+ written = []
186
+ template_path = model_dir / TEMPLATE_FILE
187
+ original = template_path.read_text(encoding="utf-8")
188
+ wrapped = wrap_chat_template(original)
189
+ if wrapped != original:
190
+ _replace(template_path, wrapped)
191
+ written.append(TEMPLATE_FILE)
192
+
193
+ eos = config.get("eos_token_id")
194
+ eos = eos[0] if isinstance(eos, list) else eos
195
+ if eos is None:
196
+ raise ValueError(f"{model_dir}/config.json has no eos_token_id")
197
+ payload = generation_config(int(eos), int(turn_end), ids[BOS])
198
+ _replace(
199
+ model_dir / GENERATION_CONFIG_FILE,
200
+ json.dumps(payload, indent=2) + "\n",
201
+ )
202
+ written.append(GENERATION_CONFIG_FILE)
203
+ return written
204
+
205
+
206
+ def main(argv=None) -> int:
207
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
208
+ parser.add_argument(
209
+ "--model", required=True, help="merged checkpoint directory (modified in place)"
210
+ )
211
+ args = parser.parse_args(argv)
212
+ for name in write_openai_assets(Path(args.model)):
213
+ print(f"-- wrote {Path(args.model) / name}")
214
+ return 0
215
+
216
+
217
+ if __name__ == "__main__":
218
+ raise SystemExit(main())
@@ -0,0 +1 @@
1
+ """Export helpers: turn the two trained artefacts into one servable directory."""
@@ -0,0 +1,382 @@
1
+ """Merge the LLM export and the audio frontend export into one servable directory.
2
+
3
+ The two trained artefacts are produced separately -- ``export_llm.py`` writes 17
4
+ safetensors shards and filters out the 403 ``audio_tower.*`` tensors,
5
+ ``export_audio.py`` writes those tensors into a single file -- because vLLM's
6
+ ``HYV3ForCausalLM`` has no idea what an audio tower is. Once the architecture is
7
+ ``HYV3SpeechForCausalLM`` it does, and the two can live in one directory that
8
+ ``vllm serve`` takes as-is.
9
+
10
+ What this writes:
11
+
12
+ * the 17 LLM shards, **hard-linked** rather than copied. They are 57 GB and
13
+ their tensor names do not change: the model class renames ``model.*`` to
14
+ ``language_model.model.*`` at load time with a ``WeightsMapper``, because
15
+ ``safetensors_weights_iterator`` reads names out of the shard files themselves
16
+ and ignores the index. Rewriting 57 GB to change a prefix would be pure waste.
17
+ A copy is used instead when the two paths are on different filesystems.
18
+ * one new shard holding the 403 audio tensors, each prefixed ``audio_tower.``.
19
+ * a rewritten ``model.safetensors.index.json``. The index only decides *which
20
+ files* get read (``filter_duplicate_safetensors_files``), so the LLM entries
21
+ are carried over unchanged and the audio entries are appended.
22
+ * a ``config.json`` with ``architectures: ["HYV3SpeechForCausalLM"]``, an
23
+ ``audio_config`` block describing the encoder, and ``audio_token_id`` /
24
+ ``turn_end_token_id`` at the top level.
25
+ * ``preprocessor_config.json`` copied from the pretrained encoder directory, so
26
+ the Whisper mel extractor resolves from the merged directory alone and the
27
+ deployment no longer needs the separate encoder tree mounted.
28
+ * an audio-aware ``chat_template.jinja`` and ``generation_config.json`` for
29
+ OpenAI-compatible ``/v1/chat/completions`` requests with ``input_audio``.
30
+ The original template remains the fallback for plain-string callers.
31
+
32
+ Source directories are read-only; everything lands in ``--target``.
33
+
34
+ python -m simulst_mm.export.merge_export \\
35
+ --llm /path/to/hf_export \\
36
+ --audio /path/to/hf_export_audio \\
37
+ --target /path/to/hf_export_unified \\
38
+ --encoder-path /path/to/pretrained_audio_encoder
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import argparse
44
+ import json
45
+ import os
46
+ import shutil
47
+ from pathlib import Path
48
+
49
+ ARCHITECTURE = "HYV3SpeechForCausalLM"
50
+ AUDIO_PREFIX = "audio_tower."
51
+ AUDIO_SHARD_NAME = "model-audio-tower.safetensors"
52
+ INDEX_NAME = "model.safetensors.index.json"
53
+
54
+ # The pretrained encoder directory, needed only for preprocessor_config.json and
55
+ # the encoder shape. Nothing is read from it at serving time once the merge has
56
+ # run, and both reads fall back to the built-in values below, so leaving this
57
+ # unset only costs a printed warning. Override with --encoder-path, or point
58
+ # SIMULST_ENCODER_PATH at it.
59
+ ENCODER_PATH = os.environ.get("SIMULST_ENCODER_PATH", "")
60
+
61
+ # The end-of-turn marker, recorded so boundary.py does not have to re-derive it
62
+ # from the tokenizer inside every worker process. Matches
63
+ # hytuner.utils.dataset.audio_sft_dataset.TURN_END_TOKEN.
64
+ TURN_END_TOKEN = "<|hy_EOT|>"
65
+
66
+ # Encoder shape. The pretrained config nests this under
67
+ # thinker_config.audio_config together with a few hundred keys of HF
68
+ # boilerplate; only the keys the vendored AudioEncoderConfig reads are carried
69
+ # over, and they are verified against the source in `_encoder_shape`.
70
+ ENCODER_SHAPE_KEYS = (
71
+ "num_mel_bins",
72
+ "encoder_layers",
73
+ "encoder_attention_heads",
74
+ "encoder_ffn_dim",
75
+ "d_model",
76
+ "max_source_positions",
77
+ "n_window",
78
+ "n_window_infer",
79
+ "conv_chunksize",
80
+ "downsample_hidden_size",
81
+ "output_dim",
82
+ )
83
+
84
+ # Used when the pretrained encoder directory is not reachable.
85
+ ENCODER_SHAPE_DEFAULT = {
86
+ "num_mel_bins": 128,
87
+ "encoder_layers": 24,
88
+ "encoder_attention_heads": 16,
89
+ "encoder_ffn_dim": 4096,
90
+ "d_model": 1024,
91
+ "max_source_positions": 1500,
92
+ "n_window": 50,
93
+ "n_window_infer": 800,
94
+ "conv_chunksize": 500,
95
+ "downsample_hidden_size": 480,
96
+ "output_dim": 2048,
97
+ }
98
+
99
+ PREPROCESSOR_DEFAULT = {
100
+ "chunk_length": 30,
101
+ "dither": 0.0,
102
+ "feature_extractor_type": "WhisperFeatureExtractor",
103
+ "feature_size": 128,
104
+ "hop_length": 160,
105
+ "n_fft": 400,
106
+ "n_samples": 480000,
107
+ "nb_max_frames": 3000,
108
+ "padding_side": "right",
109
+ "padding_value": 0.0,
110
+ "return_attention_mask": True,
111
+ }
112
+
113
+ # Everything except the shards and the index: tokenizer, chat template, licence.
114
+ PASSTHROUGH_SUFFIXES = (".json", ".jinja", ".txt", ".md")
115
+
116
+
117
+ def parse_args(argv=None):
118
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
119
+ parser.add_argument("--llm", required=True, help="LLM export directory (read-only)")
120
+ parser.add_argument("--audio", required=True, help="audio frontend export directory")
121
+ parser.add_argument("--target", required=True, help="output directory")
122
+ parser.add_argument(
123
+ "--encoder-path",
124
+ default=ENCODER_PATH,
125
+ help="pretrained encoder directory, read for preprocessor_config.json and "
126
+ "the encoder shape",
127
+ )
128
+ parser.add_argument(
129
+ "--copy-shards",
130
+ action="store_true",
131
+ help="copy the LLM shards instead of hard-linking them (57 GB)",
132
+ )
133
+ parser.add_argument("--overwrite", action="store_true", help="replace an existing target")
134
+ parser.add_argument(
135
+ "--no-openai-assets",
136
+ action="store_true",
137
+ help="do not install the audio-aware chat template and generation config",
138
+ )
139
+ return parser.parse_args(argv)
140
+
141
+
142
+ def _read_json(path: Path) -> dict:
143
+ return json.loads(path.read_text(encoding="utf-8"))
144
+
145
+
146
+ def _write_json(path: Path, payload: dict) -> None:
147
+ path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
148
+
149
+
150
+ def _encoder_shape(encoder_path: Path | None) -> dict:
151
+ """Pull the encoder shape out of the pretrained config, falling back to defaults.
152
+
153
+ A mismatch between what the pretrained config says and what the merged
154
+ config claims would produce an audio tower whose parameter shapes do not
155
+ match the trained tensors, which surfaces much later as an opaque load
156
+ error. Reading the real file keeps the two in step.
157
+ """
158
+ if encoder_path is None:
159
+ print("-- no --encoder-path given, using the built-in encoder shape")
160
+ return dict(ENCODER_SHAPE_DEFAULT)
161
+
162
+ config_file = encoder_path / "config.json"
163
+ if not config_file.is_file():
164
+ print(f"-- {config_file} not readable, using the built-in encoder shape")
165
+ return dict(ENCODER_SHAPE_DEFAULT)
166
+
167
+ config = _read_json(config_file)
168
+ audio = config.get("thinker_config", config).get("audio_config")
169
+ if not isinstance(audio, dict):
170
+ print(f"-- no thinker_config.audio_config in {config_file}, using defaults")
171
+ return dict(ENCODER_SHAPE_DEFAULT)
172
+
173
+ shape = {key: audio[key] for key in ENCODER_SHAPE_KEYS if key in audio}
174
+ missing = [key for key in ENCODER_SHAPE_KEYS if key not in shape]
175
+ for key in missing:
176
+ shape[key] = ENCODER_SHAPE_DEFAULT[key]
177
+ if missing:
178
+ print(f"-- encoder config is missing {missing}, defaulted")
179
+
180
+ differing = {
181
+ key: (ENCODER_SHAPE_DEFAULT[key], value)
182
+ for key, value in shape.items()
183
+ if ENCODER_SHAPE_DEFAULT[key] != value
184
+ }
185
+ if differing:
186
+ print(f"-- encoder shape differs from the expected one: {differing}")
187
+ return shape
188
+
189
+
190
+ def _preprocessor_config(encoder_path: Path | None) -> dict:
191
+ if encoder_path is None:
192
+ print("-- no --encoder-path given, writing the built-in preprocessor config")
193
+ return dict(PREPROCESSOR_DEFAULT)
194
+
195
+ source = encoder_path / "preprocessor_config.json"
196
+ if not source.is_file():
197
+ print(f"-- {source} not readable, writing the built-in preprocessor config")
198
+ return dict(PREPROCESSOR_DEFAULT)
199
+
200
+ config = _read_json(source)
201
+ # processor_class names a Qwen3ASRProcessor that is not installed here and
202
+ # is not needed: only the WhisperFeatureExtractor fields are used.
203
+ config.pop("processor_class", None)
204
+ return config
205
+
206
+
207
+ def _link_or_copy(source: Path, target: Path, *, copy: bool) -> str:
208
+ if target.exists():
209
+ target.unlink()
210
+ if not copy:
211
+ try:
212
+ os.link(source, target)
213
+ return "link"
214
+ except OSError:
215
+ # Different filesystem, or a mount that does not allow links.
216
+ pass
217
+ shutil.copy2(source, target)
218
+ return "copy"
219
+
220
+
221
+ def _turn_end_token_id(llm_dir: Path) -> int | None:
222
+ """Resolve the EOT id from the exported tokenizer, if it is readable."""
223
+ try:
224
+ from transformers import AutoTokenizer
225
+
226
+ tokenizer = AutoTokenizer.from_pretrained(str(llm_dir), local_files_only=True)
227
+ encoded = list(tokenizer.encode(TURN_END_TOKEN, add_special_tokens=False))
228
+ except Exception as error:
229
+ print(f"-- could not resolve {TURN_END_TOKEN!r} from the tokenizer: {error}")
230
+ return None
231
+
232
+ if len(encoded) != 1:
233
+ print(f"-- {TURN_END_TOKEN!r} encodes to {encoded}, not a single id; skipping")
234
+ return None
235
+ return int(encoded[0])
236
+
237
+
238
+ def main(argv=None) -> int:
239
+ args = parse_args(argv)
240
+
241
+ llm_dir = Path(args.llm)
242
+ audio_dir = Path(args.audio)
243
+ target = Path(args.target)
244
+ # Empty means "not supplied": Path("") is Path("."), which would silently
245
+ # read ./config.json from wherever the merge happens to run.
246
+ encoder_path = Path(args.encoder_path) if args.encoder_path else None
247
+
248
+ index_path = llm_dir / INDEX_NAME
249
+ audio_weights = audio_dir / "audio_frontend.safetensors"
250
+ audio_manifest_path = audio_dir / "audio_frontend.json"
251
+ for required in (llm_dir / "config.json", index_path, audio_weights, audio_manifest_path):
252
+ if not required.is_file():
253
+ raise SystemExit(f"missing required input: {required}")
254
+
255
+ turn_end_id = _turn_end_token_id(llm_dir)
256
+ if not args.no_openai_assets:
257
+ for required in (
258
+ llm_dir / "chat_template.jinja",
259
+ llm_dir / "tokenizer_config.json",
260
+ ):
261
+ if not required.is_file():
262
+ raise SystemExit(f"OpenAI chat assets require: {required}")
263
+ if turn_end_id is None:
264
+ raise SystemExit(
265
+ f"OpenAI chat assets require {TURN_END_TOKEN!r} to encode to one token"
266
+ )
267
+
268
+ if target.exists():
269
+ if not args.overwrite:
270
+ raise SystemExit(f"{target} already exists (pass --overwrite to replace it)")
271
+ if target.resolve() in (llm_dir.resolve(), audio_dir.resolve()):
272
+ raise SystemExit("refusing to overwrite a source directory")
273
+ print(f"-- removing existing {target}")
274
+ shutil.rmtree(target)
275
+ target.mkdir(parents=True)
276
+
277
+ from safetensors.torch import load_file, save_file
278
+
279
+ # --- audio tensors -----------------------------------------------------
280
+ audio_manifest = _read_json(audio_manifest_path)
281
+ audio_tensors = load_file(str(audio_weights))
282
+ prefixed = {AUDIO_PREFIX + name: tensor for name, tensor in audio_tensors.items()}
283
+ audio_bytes = sum(tensor.numel() * tensor.element_size() for tensor in prefixed.values())
284
+ save_file(prefixed, str(target / AUDIO_SHARD_NAME), metadata={"format": "pt"})
285
+ print(f"-- wrote {len(prefixed)} audio tensors ({audio_bytes / 1e9:.2f} GB) under {AUDIO_PREFIX}")
286
+
287
+ # --- LLM shards --------------------------------------------------------
288
+ index = _read_json(index_path)
289
+ weight_map: dict[str, str] = dict(index["weight_map"])
290
+ shard_names = sorted(set(weight_map.values()))
291
+
292
+ mode = None
293
+ for shard in shard_names:
294
+ mode = _link_or_copy(llm_dir / shard, target / shard, copy=args.copy_shards)
295
+ print(f"-- {mode}ed {len(shard_names)} LLM shards ({len(weight_map)} tensors)")
296
+
297
+ # --- index -------------------------------------------------------------
298
+ # LLM names stay as they are; HYV3SpeechForCausalLM.hf_to_vllm_mapper does
299
+ # the `model.` -> `language_model.model.` rename when the weights are read.
300
+ for name in prefixed:
301
+ weight_map[name] = AUDIO_SHARD_NAME
302
+ total_size = int(index.get("metadata", {}).get("total_size", 0)) + audio_bytes
303
+ _write_json(target / INDEX_NAME, {"metadata": {"total_size": total_size}, "weight_map": weight_map})
304
+
305
+ # --- config ------------------------------------------------------------
306
+ config = _read_json(llm_dir / "config.json")
307
+ config["architectures"] = [ARCHITECTURE]
308
+ config["audio_token_id"] = int(audio_manifest["audio_token_id"])
309
+
310
+ if turn_end_id is not None:
311
+ # Read by boundary.py, which has no tokenizer of its own.
312
+ config["turn_end_token_id"] = turn_end_id
313
+
314
+ audio_config = _encoder_shape(encoder_path)
315
+ audio_config.update(
316
+ # The deployed frontend leaves attn_implementation at the transformers
317
+ # default, which on this install is sdpa. Pinned so a future default
318
+ # cannot silently change the numerics.
319
+ attn_implementation="sdpa",
320
+ attention_dropout=0.0,
321
+ encoder_dim=int(audio_manifest["encoder_dim"]),
322
+ fsk_size=int(audio_manifest["fsk_size"]),
323
+ llm_dim=int(audio_manifest["llm_dim"]),
324
+ sample_rate=int(audio_manifest["sample_rate"]),
325
+ hop_length=int(audio_manifest["hop_length"]),
326
+ max_samples=int(audio_manifest["max_samples"]),
327
+ )
328
+ if audio_config["llm_dim"] != config["hidden_size"]:
329
+ raise SystemExit(
330
+ f"llm_dim {audio_config['llm_dim']} does not match the LLM hidden_size "
331
+ f"{config['hidden_size']}; the adapter output would not fit the embedding space"
332
+ )
333
+ if audio_config["encoder_dim"] != audio_config["output_dim"]:
334
+ raise SystemExit(
335
+ f"encoder_dim {audio_config['encoder_dim']} does not match the encoder "
336
+ f"output_dim {audio_config['output_dim']}"
337
+ )
338
+ config["audio_config"] = audio_config
339
+ _write_json(target / "config.json", config)
340
+
341
+ _write_json(target / "preprocessor_config.json", _preprocessor_config(encoder_path))
342
+
343
+ # --- tokenizer and the rest -------------------------------------------
344
+ carried = []
345
+ for entry in sorted(llm_dir.iterdir()):
346
+ if not entry.is_file() or entry.suffix not in PASSTHROUGH_SUFFIXES:
347
+ continue
348
+ if entry.name in {"config.json", INDEX_NAME}:
349
+ continue
350
+ shutil.copy2(entry, target / entry.name)
351
+ carried.append(entry.name)
352
+ print(f"-- carried over {len(carried)} auxiliary file(s): {', '.join(carried)}")
353
+
354
+ if args.no_openai_assets:
355
+ print("-- skipped OpenAI chat assets (--no-openai-assets)")
356
+ else:
357
+ from simulst_mm.chat_template import write_openai_assets
358
+
359
+ for name in write_openai_assets(target):
360
+ print(f"-- wrote {name} for /v1/chat/completions")
361
+
362
+ _write_json(
363
+ target / "merge_manifest.json",
364
+ {
365
+ "architecture": ARCHITECTURE,
366
+ "llm_source": str(llm_dir.resolve()),
367
+ "audio_source": str(audio_dir.resolve()),
368
+ "encoder_source": str(encoder_path) if encoder_path else None,
369
+ "llm_tensors": len(index["weight_map"]),
370
+ "audio_tensors": len(prefixed),
371
+ "audio_shard": AUDIO_SHARD_NAME,
372
+ "shards_materialised_by": "copy" if args.copy_shards else (mode or "link"),
373
+ "total_size": total_size,
374
+ },
375
+ )
376
+
377
+ print(f"-- merged into {target}")
378
+ return 0
379
+
380
+
381
+ if __name__ == "__main__": # pragma: no cover
382
+ raise SystemExit(main())