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.
- simulst-0.1.0.dist-info/METADATA +394 -0
- simulst-0.1.0.dist-info/RECORD +18 -0
- simulst-0.1.0.dist-info/WHEEL +5 -0
- simulst-0.1.0.dist-info/entry_points.txt +5 -0
- simulst-0.1.0.dist-info/licenses/LICENSE +201 -0
- simulst-0.1.0.dist-info/top_level.txt +1 -0
- simulst_mm/__init__.py +31 -0
- simulst_mm/chat_template.py +218 -0
- simulst_mm/export/__init__.py +1 -0
- simulst_mm/export/merge_export.py +382 -0
- simulst_mm/model/audio_encoder.py +383 -0
- simulst_mm/model/audio_tower.py +194 -0
- simulst_mm/model/boundary.py +169 -0
- simulst_mm/model/hy_v3_speech.py +209 -0
- simulst_mm/model/processing.py +310 -0
- simulst_mm/plugin.py +71 -0
- simulst_mm/serving/__init__.py +5 -0
- simulst_mm/serving/async_backend.py +278 -0
|
@@ -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())
|