yongqiang commited on
Commit
df5639f
·
1 Parent(s): 5099405

Bootstrap nano AXERA package scaffold

Browse files
.gitignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ vision_cache/
4
+ *.tmp
5
+ tmp/
6
+ WORKFLOW.md
7
+ model.embed_tokens.weight.npy
8
+ python/testdata/axllm_service_*_summary.json
9
+ python/testdata/openai_api_hf_multimodal_compare/
README.md CHANGED
@@ -1,3 +1,72 @@
1
  ---
2
- license: bsd-3-clause
 
 
 
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ license: cc-by-nc-4.0
3
+ base_model:
4
+ - jinaai/jina-embeddings-v5-omni-nano
5
+ tags:
6
+ - axera
7
+ - ax650
8
+ - embeddings
9
+ - multimodal
10
+ - image
11
+ - audio
12
+ - video
13
  ---
14
+
15
+ # jina-embeddings-v5-omni-nano on AXERA NPU
16
+
17
+ This repository is the staging workspace for the future AX650 deployment package of `jinaai/jina-embeddings-v5-omni-nano`.
18
+
19
+ It is not upload-ready yet. The static package skeleton is prepared first, and the compiled AX650 runtime artifacts will be added after conversion and validation.
20
+
21
+ ## Current Preparation Scope
22
+
23
+ - Target platform: AX650 / NPU3
24
+ - Reuse the existing developer conversion workspace: `jina_embeddings_v5_omni_s.axera`
25
+ - Planned vision encoder static shape: `256x256`
26
+ - Planned `pulsar2 llm_build` chunk prefill length: `128`
27
+ - Other compile-time assumptions are currently kept aligned with the validated `jina-embeddings-v5-omni-small` workflow until nano-specific validation completes
28
+
29
+ ## Important Differences vs `jina-embeddings-v5-omni-small`
30
+
31
+ - Upstream embedding dimension is `768`, not `1024`
32
+ - The upstream text backbone is a bidirectional Llama / EuroBERT-style encoder path rather than the current small-package Qwen3-style text branch
33
+ - Retrieval still distinguishes `Query: ...` from `Document: ...`
34
+ - Upstream multimodal raw processing uses a shared image/video placeholder path, so video handling should be revalidated carefully during AXERA packaging
35
+
36
+ ## What Is Already Included
37
+
38
+ - Packaged `axllm` runtime binary under `bin/`
39
+ - Demo assets under `assets/`
40
+ - Upstream nano tokenizer / processor / remote-code files under `jina_v5_omni_tokenizer/`
41
+ - Basic OpenAI-compatible API demo scripts under `python/`
42
+
43
+ ## What Is Still Missing
44
+
45
+ - Compiled text / vision / audio `.axmodel` files
46
+ - Runtime root `config.json`
47
+ - Extracted embedding weight bin
48
+ - Packaged precision-reference cases under `python/testdata/service_cases/`
49
+ - Final AX650 README with measured latency, memory, token counts, and precision numbers
50
+
51
+ ## Intended Final Layout
52
+
53
+ The final package will follow the same high-level AXERA release layout as the validated small package:
54
+
55
+ ```text
56
+ .
57
+ ├── README.md
58
+ ├── config.json
59
+ ├── jina_v5_omni_tokenizer/
60
+ ├── bin/
61
+ │ └── axllm
62
+ ├── *.axmodel
63
+ ├── model.embed_tokens.weight.bfloat16.bin
64
+ ├── python/
65
+ │ ├── openai_embedding_demo.py
66
+ │ ├── openai_multimodal_embedding_demo.py
67
+ │ ├── compare_openai_api_vs_hf_multimodal.py
68
+ │ └── verify_axllm_service.py
69
+ └── assets/
70
+ ```
71
+
72
+ Until the compiled artifacts are added and validated, this repository should be treated as a release-package scaffold rather than a runnable final package.
assets/audio_test_chunk0_30s.wav ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:ddc9d854e322bec2eac0cd5be7603a159fb0a755fe42846b1c6e550aa897bae8
3
+ size 5292294
assets/red-panda-openai.frames/0000.png ADDED

Git LFS Details

  • SHA256: fe6d3a9ddbe5c43c01e6e9c0e9745b62ccf61c341060dd26fccab85461fba81a
  • Pointer size: 131 Bytes
  • Size of remote file: 371 kB
assets/red-panda-openai.frames/0127.png ADDED

Git LFS Details

  • SHA256: 79b6f4895c67e436caa0256ce2b8a48dd2bd675c19e27eabb74898523ca23bfc
  • Pointer size: 131 Bytes
  • Size of remote file: 377 kB
assets/red-panda-openai.frames/0255.png ADDED

Git LFS Details

  • SHA256: 3da238ed41f4c34eb50c47aee703cdaf20966ac074b40e9928c72981f183d102
  • Pointer size: 131 Bytes
  • Size of remote file: 272 kB
assets/red-panda-openai.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:d921c07bb97224d65a37801541d246067f0d506f08723ffa1ad85c217907ccb8
3
+ size 1867237
assets/sample.png ADDED

Git LFS Details

  • SHA256: e10040a55718d1e1157d919fd44051728bc9329e0e28008f940c6a316712e0cf
  • Pointer size: 131 Bytes
  • Size of remote file: 838 kB
bin/axllm ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:0c7f041cd5591d221df98de7e4bcc9223f936e47a99143c009dadf59b96c6b87
3
+ size 2384160
jina_v5_omni_tokenizer/chat_template.jinja ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {%- set image_count = namespace(value=0) %}
2
+ {%- set video_count = namespace(value=0) %}
3
+ {%- macro render_content(content, do_vision_count, is_system_content=false) %}
4
+ {%- if content is string %}
5
+ {{- content }}
6
+ {%- elif content is iterable and content is not mapping %}
7
+ {%- for item in content %}
8
+ {%- if 'image' in item or 'image_url' in item or item.type == 'image' %}
9
+ {%- if is_system_content %}
10
+ {{- raise_exception('System message cannot contain images.') }}
11
+ {%- endif %}
12
+ {%- if do_vision_count %}
13
+ {%- set image_count.value = image_count.value + 1 %}
14
+ {%- endif %}
15
+ {%- if add_vision_id %}
16
+ {{- 'Picture ' ~ image_count.value ~ ': ' }}
17
+ {%- endif %}
18
+ {{- '<|vision_start|><|image_pad|><|vision_end|>' }}
19
+ {%- elif 'video' in item or item.type == 'video' %}
20
+ {%- if is_system_content %}
21
+ {{- raise_exception('System message cannot contain videos.') }}
22
+ {%- endif %}
23
+ {%- if do_vision_count %}
24
+ {%- set video_count.value = video_count.value + 1 %}
25
+ {%- endif %}
26
+ {%- if add_vision_id %}
27
+ {{- 'Video ' ~ video_count.value ~ ': ' }}
28
+ {%- endif %}
29
+ {{- '<|vision_start|><|video_pad|><|vision_end|>' }}
30
+ {%- elif 'text' in item %}
31
+ {{- item.text }}
32
+ {%- else %}
33
+ {{- raise_exception('Unexpected item type in content.') }}
34
+ {%- endif %}
35
+ {%- endfor %}
36
+ {%- elif content is none or content is undefined %}
37
+ {{- '' }}
38
+ {%- else %}
39
+ {{- raise_exception('Unexpected content type.') }}
40
+ {%- endif %}
41
+ {%- endmacro %}
42
+ {%- if not messages %}
43
+ {{- raise_exception('No messages provided.') }}
44
+ {%- endif %}
45
+ {%- if tools and tools is iterable and tools is not mapping %}
46
+ {{- '<|im_start|>system\n' }}
47
+ {{- "# Tools\n\nYou have access to the following functions:\n\n<tools>" }}
48
+ {%- for tool in tools %}
49
+ {{- "\n" }}
50
+ {{- tool | tojson }}
51
+ {%- endfor %}
52
+ {{- "\n</tools>" }}
53
+ {{- '\n\nIf you choose to call a function ONLY reply in the following format with NO suffix:\n\n<tool_call>\n<function=example_function_name>\n<parameter=example_parameter_1>\nvalue_1\n</parameter>\n<parameter=example_parameter_2>\nThis is the value for the second parameter\nthat can span\nmultiple lines\n</parameter>\n</function>\n</tool_call>\n\n<IMPORTANT>\nReminder:\n- Function calls MUST follow the specified format: an inner <function=...></function> block must be nested within <tool_call></tool_call> XML tags\n- Required parameters MUST be specified\n- You may provide optional reasoning for your function call in natural language BEFORE the function call, but NOT after\n- If there is no function call available, answer the question like normal with your current knowledge and do not tell the user about function calls\n</IMPORTANT>' }}
54
+ {%- if messages[0].role == 'system' %}
55
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
56
+ {%- if content %}
57
+ {{- '\n\n' + content }}
58
+ {%- endif %}
59
+ {%- endif %}
60
+ {{- '<|im_end|>\n' }}
61
+ {%- else %}
62
+ {%- if messages[0].role == 'system' %}
63
+ {%- set content = render_content(messages[0].content, false, true)|trim %}
64
+ {{- '<|im_start|>system\n' + content + '<|im_end|>\n' }}
65
+ {%- endif %}
66
+ {%- endif %}
67
+ {%- set ns = namespace(multi_step_tool=true, last_query_index=messages|length - 1) %}
68
+ {%- for message in messages[::-1] %}
69
+ {%- set index = (messages|length - 1) - loop.index0 %}
70
+ {%- if ns.multi_step_tool and message.role == "user" %}
71
+ {%- set content = render_content(message.content, false)|trim %}
72
+ {%- if not(content.startswith('<tool_response>') and content.endswith('</tool_response>')) %}
73
+ {%- set ns.multi_step_tool = false %}
74
+ {%- set ns.last_query_index = index %}
75
+ {%- endif %}
76
+ {%- endif %}
77
+ {%- endfor %}
78
+ {%- if ns.multi_step_tool %}
79
+ {{- raise_exception('No user query found in messages.') }}
80
+ {%- endif %}
81
+ {%- for message in messages %}
82
+ {%- set content = render_content(message.content, true)|trim %}
83
+ {%- if message.role == "system" %}
84
+ {%- if not loop.first %}
85
+ {{- raise_exception('System message must be at the beginning.') }}
86
+ {%- endif %}
87
+ {%- elif message.role == "user" %}
88
+ {{- '<|im_start|>' + message.role + '\n' + content + '<|im_end|>' + '\n' }}
89
+ {%- elif message.role == "assistant" %}
90
+ {%- set reasoning_content = '' %}
91
+ {%- if message.reasoning_content is string %}
92
+ {%- set reasoning_content = message.reasoning_content %}
93
+ {%- else %}
94
+ {%- if '</think>' in content %}
95
+ {%- set reasoning_content = content.split('</think>')[0].rstrip('\n').split('<think>')[-1].lstrip('\n') %}
96
+ {%- set content = content.split('</think>')[-1].lstrip('\n') %}
97
+ {%- endif %}
98
+ {%- endif %}
99
+ {%- set reasoning_content = reasoning_content|trim %}
100
+ {%- if loop.index0 > ns.last_query_index %}
101
+ {{- '<|im_start|>' + message.role + '\n<think>\n' + reasoning_content + '\n</think>\n\n' + content }}
102
+ {%- else %}
103
+ {{- '<|im_start|>' + message.role + '\n' + content }}
104
+ {%- endif %}
105
+ {%- if message.tool_calls and message.tool_calls is iterable and message.tool_calls is not mapping %}
106
+ {%- for tool_call in message.tool_calls %}
107
+ {%- if tool_call.function is defined %}
108
+ {%- set tool_call = tool_call.function %}
109
+ {%- endif %}
110
+ {%- if loop.first %}
111
+ {%- if content|trim %}
112
+ {{- '\n\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
113
+ {%- else %}
114
+ {{- '<tool_call>\n<function=' + tool_call.name + '>\n' }}
115
+ {%- endif %}
116
+ {%- else %}
117
+ {{- '\n<tool_call>\n<function=' + tool_call.name + '>\n' }}
118
+ {%- endif %}
119
+ {%- if tool_call.arguments is defined %}
120
+ {%- for args_name, args_value in tool_call.arguments|items %}
121
+ {{- '<parameter=' + args_name + '>\n' }}
122
+ {%- set args_value = args_value | tojson | safe if args_value is mapping or (args_value is sequence and args_value is not string) else args_value | string %}
123
+ {{- args_value }}
124
+ {{- '\n</parameter>\n' }}
125
+ {%- endfor %}
126
+ {%- endif %}
127
+ {{- '</function>\n</tool_call>' }}
128
+ {%- endfor %}
129
+ {%- endif %}
130
+ {{- '<|im_end|>\n' }}
131
+ {%- elif message.role == "tool" %}
132
+ {%- if loop.previtem and loop.previtem.role != "tool" %}
133
+ {{- '<|im_start|>user' }}
134
+ {%- endif %}
135
+ {{- '\n<tool_response>\n' }}
136
+ {{- content }}
137
+ {{- '\n</tool_response>' }}
138
+ {%- if not loop.last and loop.nextitem.role != "tool" %}
139
+ {{- '<|im_end|>\n' }}
140
+ {%- elif loop.last %}
141
+ {{- '<|im_end|>\n' }}
142
+ {%- endif %}
143
+ {%- else %}
144
+ {{- raise_exception('Unexpected message role.') }}
145
+ {%- endif %}
146
+ {%- endfor %}
147
+ {%- if add_generation_prompt %}
148
+ {{- '<|im_start|>assistant\n' }}
149
+ {%- if enable_thinking is defined and enable_thinking is true %}
150
+ {{- '<think>\n' }}
151
+ {%- else %}
152
+ {{- '<think>\n\n</think>\n\n' }}
153
+ {%- endif %}
154
+ {%- endif %}
jina_v5_omni_tokenizer/config.json ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "architectures": [
3
+ "JinaEmbeddingsV5OmniModel"
4
+ ],
5
+ "auto_map": {
6
+ "AutoConfig": "modeling_jina_embeddings_v5_omni.JinaEmbeddingsV5OmniConfig",
7
+ "AutoModel": "modeling_jina_embeddings_v5_omni.JinaEmbeddingsV5OmniModel"
8
+ },
9
+ "model_type": "jina_embeddings_v5_omni",
10
+ "task_names": [
11
+ "retrieval",
12
+ "text-matching",
13
+ "clustering",
14
+ "classification"
15
+ ],
16
+ "special_token_ids": [
17
+ 128256,
18
+ 128257,
19
+ 128258,
20
+ 128259
21
+ ],
22
+ "vision_config": {
23
+ "deepstack_visual_indexes": [],
24
+ "depth": 12,
25
+ "dtype": "bfloat16",
26
+ "hidden_act": "gelu_pytorch_tanh",
27
+ "hidden_size": 768,
28
+ "in_channels": 3,
29
+ "initializer_range": 0.02,
30
+ "intermediate_size": 3072,
31
+ "model_type": "",
32
+ "num_heads": 12,
33
+ "num_position_embeddings": 2304,
34
+ "out_hidden_size": 1024,
35
+ "patch_size": 16,
36
+ "spatial_merge_size": 2,
37
+ "temporal_patch_size": 2
38
+ },
39
+ "text_config": {
40
+ "attention_bias": false,
41
+ "attention_dropout": 0.0,
42
+ "bos_token_id": 1,
43
+ "eos_token_id": 2,
44
+ "head_dim": 64,
45
+ "hidden_act": "silu",
46
+ "hidden_size": 768,
47
+ "initializer_range": 0.02,
48
+ "intermediate_size": 3072,
49
+ "is_causal": false,
50
+ "max_position_embeddings": 8192,
51
+ "mlp_bias": false,
52
+ "model_type": "",
53
+ "num_attention_heads": 12,
54
+ "num_hidden_layers": 12,
55
+ "num_key_value_heads": 12,
56
+ "pad_token_id": null,
57
+ "pretraining_tp": 1,
58
+ "rms_norm_eps": 1e-05,
59
+ "rope_parameters": {
60
+ "rope_theta": 1000000.0,
61
+ "rope_type": "default"
62
+ },
63
+ "tie_word_embeddings": false,
64
+ "vocab_size": 128260
65
+ },
66
+ "audio_config": {
67
+ "activation_dropout": 0.0,
68
+ "activation_function": "gelu",
69
+ "attention_dropout": 0.0,
70
+ "d_model": 1280,
71
+ "dropout": 0.0,
72
+ "dtype": "float32",
73
+ "encoder_attention_heads": 20,
74
+ "encoder_ffn_dim": 5120,
75
+ "encoder_layers": 32,
76
+ "initializer_range": 0.02,
77
+ "max_source_positions": 1500,
78
+ "num_mel_bins": 128,
79
+ "scale_embedding": false,
80
+ "n_window": 100,
81
+ "output_dim": 3584
82
+ },
83
+ "image_token_index": 128259,
84
+ "audio_token_id": 128256,
85
+ "audio_start_token_id": 128257,
86
+ "audio_end_token_id": 128258,
87
+ "projector_hidden_act": "gelu",
88
+ "tie_word_embeddings": false,
89
+ "dtype": "bfloat16",
90
+ "transformers_version": "5.4.0",
91
+ "torch_dtype": "bfloat16",
92
+ "is_matryoshka": true,
93
+ "matryoshka_dimensions": [
94
+ 32,
95
+ 64,
96
+ 128,
97
+ 256,
98
+ 512,
99
+ 768
100
+ ]
101
+ }
jina_v5_omni_tokenizer/config_sentence_transformers.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "prompts": {
3
+ "query": "Query: ",
4
+ "document": "Document: "
5
+ },
6
+ "default_prompt_name": null,
7
+ "similarity_fn_name": "cosine"
8
+ }
jina_v5_omni_tokenizer/custom_st.py ADDED
@@ -0,0 +1,990 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Sentence-transformers integration for jina-embeddings-v5-omni-nano (base + LoRA).
2
+
3
+ Supports text, image, video, and audio with per-task adapter routing:
4
+
5
+ from sentence_transformers import SentenceTransformer
6
+ model = SentenceTransformer(
7
+ "jinaai/jina-embeddings-v5-omni-nano",
8
+ trust_remote_code=True,
9
+ model_kwargs={"default_task": "retrieval"},
10
+ )
11
+ q = model.encode("What is ML?", prompt_name="query")
12
+ d = model.encode("ML is ...", prompt_name="document")
13
+ img = model.encode(Image.open("photo.jpg"))
14
+ vid = model.encode("clip.mp4")
15
+ aud = model.encode("speech.wav")
16
+ """
17
+
18
+ import json
19
+ import os
20
+ from typing import Any, Dict, List, Optional, Union
21
+
22
+ import torch
23
+ import torch.nn.functional as F
24
+ from torch import nn
25
+ from transformers import AutoConfig, AutoModel, AutoTokenizer
26
+
27
+ MAX_SEQ_LENGTH = 8192
28
+ IMAGE_PROMPT = "<image>"
29
+ VIDEO_PROMPT = "<image>"
30
+ AUDIO_EXTENSIONS = {".wav", ".mp3", ".flac", ".ogg", ".m4a", ".opus", ".webm"}
31
+ VIDEO_EXTENSIONS = {".mp4", ".avi", ".mov", ".mkv", ".webm", ".flv", ".wmv"}
32
+ PDF_EXTENSIONS = {".pdf"}
33
+ SVG_EXTENSIONS = {".svg"}
34
+ PDF_DPI = 150
35
+ TASK_NAMES = ["retrieval", "text-matching", "clustering", "classification"]
36
+ EVAL_IMAGE_MIN_PIXELS = 262144
37
+ EVAL_IMAGE_MAX_PIXELS = 1310720
38
+ EVAL_VIDEO_MAX_PIXELS = 12845056
39
+ EVAL_VIDEO_NUM_FRAMES = 32
40
+
41
+
42
+ def _pil_image():
43
+ """Return the PIL.Image module, with a clean ImportError if pillow is not
44
+ installed. Wrapped in `try` so transformers' AST-based `check_imports`
45
+ does not list PIL as a top-level required dependency: text-only and
46
+ audio-only users should not need pillow installed.
47
+ """
48
+ try:
49
+ from PIL import Image as _PILImage
50
+ except ImportError as e:
51
+ raise ImportError(
52
+ "Encoding images or rasterising PDFs needs `pip install pillow`."
53
+ ) from e
54
+ return _PILImage
55
+
56
+
57
+ def _is_image(x) -> bool:
58
+ try:
59
+ from PIL import Image as PILImage
60
+ return isinstance(x, PILImage.Image)
61
+ except ImportError:
62
+ return False
63
+
64
+
65
+ def _is_video_path(x) -> bool:
66
+ if not isinstance(x, str):
67
+ return False
68
+ return any(x.lower().endswith(ext) for ext in VIDEO_EXTENSIONS)
69
+
70
+
71
+ def _is_audio_path(x) -> bool:
72
+ if not isinstance(x, str):
73
+ return False
74
+ return any(x.lower().endswith(ext) for ext in AUDIO_EXTENSIONS)
75
+
76
+
77
+ def _is_pdf_path(x) -> bool:
78
+ if not isinstance(x, str):
79
+ return False
80
+ return any(x.lower().endswith(ext) for ext in PDF_EXTENSIONS)
81
+
82
+
83
+ def _is_svg_path(x) -> bool:
84
+ if not isinstance(x, str):
85
+ return False
86
+ return any(x.lower().split("?", 1)[0].endswith(ext) for ext in SVG_EXTENSIONS)
87
+
88
+
89
+ def _is_audio_array(x) -> bool:
90
+ try:
91
+ import numpy as np
92
+ except ImportError:
93
+ return False
94
+ return isinstance(x, np.ndarray) and x.ndim == 1 and np.issubdtype(x.dtype, np.floating)
95
+
96
+
97
+ class _AudioWrapper:
98
+ def __init__(self, array, sampling_rate: int = 16000):
99
+ self.array = array
100
+ self.sampling_rate = sampling_rate
101
+
102
+
103
+ def _download_if_url(x):
104
+ """If x is an http(s) URL, download to a hashed local cache and return the
105
+ local path. Otherwise return x unchanged.
106
+ """
107
+ if not isinstance(x, str):
108
+ return x
109
+ if not (x.startswith("http://") or x.startswith("https://")):
110
+ return x
111
+ import hashlib, os, tempfile, urllib.request
112
+ from urllib.parse import urlparse
113
+ cache = os.path.join(tempfile.gettempdir(), "jina_omni_media_cache")
114
+ os.makedirs(cache, exist_ok=True)
115
+ h = hashlib.sha256(x.encode("utf-8")).hexdigest()[:16]
116
+ url_path = urlparse(x).path
117
+ _, ext = os.path.splitext(url_path)
118
+ local = os.path.join(cache, f"{h}{ext}" if ext else h)
119
+ if not os.path.isfile(local) or os.path.getsize(local) == 0:
120
+ urllib.request.urlretrieve(x, local)
121
+ return local
122
+
123
+
124
+ def _looks_like_svg(data):
125
+ if not data:
126
+ return False
127
+ head = data[:4096].lstrip().lower()
128
+ return b"<svg" in head
129
+
130
+
131
+ def _svg_to_image(svg):
132
+ try:
133
+ import cairosvg
134
+ except ImportError as e:
135
+ raise ImportError("Encoding SVG images needs `pip install cairosvg pillow`.") from e
136
+ import io
137
+ png = cairosvg.svg2png(bytestring=svg if isinstance(svg, (bytes, bytearray)) else None,
138
+ url=svg if isinstance(svg, str) else None)
139
+ _PILImage = _pil_image()
140
+ return _PILImage.open(io.BytesIO(png)).convert("RGB")
141
+
142
+
143
+ def _sniff_media_type_bytes(head):
144
+ """Return 'image'/'svg'/'video'/'audio'/'pdf'/None from content headers."""
145
+ if _looks_like_svg(head):
146
+ return "svg"
147
+ if not head or len(head) < 8:
148
+ return None
149
+ if head[:3] == b"\xff\xd8\xff": return "image"
150
+ if head[:8] == b"\x89PNG\r\n\x1a\n": return "image"
151
+ if head[:6] in (b"GIF87a", b"GIF89a"): return "image"
152
+ if head[:4] == b"RIFF" and head[8:12] == b"WEBP": return "image"
153
+ if head[:2] == b"BM": return "image"
154
+ if head[:4] in (b"II*\x00", b"MM\x00*"): return "image"
155
+ if head[4:12] in (b"ftypavif", b"ftypavis"): return "image"
156
+ if head[4:12] in (b"ftypheic", b"ftypheix", b"ftypmif1", b"ftypmsf1"):
157
+ return "image"
158
+ if head[:3] == b"ID3": return "audio"
159
+ if head[:2] in (b"\xff\xfb", b"\xff\xf3", b"\xff\xf2"): return "audio"
160
+ if head[:4] == b"fLaC": return "audio"
161
+ if head[:4] == b"OggS": return "audio"
162
+ if head[:4] == b"RIFF" and head[8:12] == b"WAVE": return "audio"
163
+ if head[4:12] in (b"M4A ", b"M4B ", b"M4P "): return "audio"
164
+ if head[:4] == b"\x1a\x45\xdf\xa3": return "video"
165
+ if head[4:8] == b"ftyp": return "video"
166
+ if head[:4] == b"RIFF" and head[8:12] == b"AVI ": return "video"
167
+ if head[:3] == b"FLV": return "video"
168
+ if head[:4] == b"0&\xb2u": return "video"
169
+ if head[:5] == b"%PDF-": return "pdf"
170
+ return None
171
+
172
+
173
+ def _sniff_media_type(path):
174
+ try:
175
+ with open(path, "rb") as f:
176
+ data = f.read(4096)
177
+ kind = _sniff_media_type_bytes(data)
178
+ if kind is None and _is_svg_path(path):
179
+ return "svg"
180
+ return kind
181
+ except OSError:
182
+ return None
183
+
184
+
185
+ def _resolve_input(x):
186
+ """Normalize any input to (kind, value). Accepts:
187
+ - PIL.Image -> image
188
+ - np.ndarray HxWx3 uint8 -> image (via PIL.fromarray)
189
+ - np.ndarray TxHxWx3 uint8 -> video (saved to /tmp via imageio)
190
+ - np.ndarray 1-D float -> audio
191
+ - np.ndarray 2-D float (C,N) or (N,C) -> audio (mono mixdown)
192
+ - torch.Tensor -> converted to numpy, recurse
193
+ - bytes / io.IOBase -> sniff + route
194
+ - str URL -> downloaded + routed
195
+ - str path -> content-sniffed + routed
196
+ - str -> text
197
+ """
198
+ import os as _os
199
+ import io
200
+
201
+ if _is_image(x):
202
+ return ("image", x)
203
+ if _is_audio_array(x):
204
+ return ("audio", x)
205
+
206
+ try:
207
+ import numpy as _np
208
+ except ImportError:
209
+ _np = None
210
+
211
+ if _np is not None and isinstance(x, _np.ndarray):
212
+ # Image (H,W,3|4) uint8
213
+ if x.ndim == 3 and x.shape[-1] in (3, 4) and x.dtype == _np.uint8:
214
+ _PILImage = _pil_image()
215
+ mode = "RGBA" if x.shape[-1] == 4 else "RGB"
216
+ return ("image", _PILImage.fromarray(x, mode).convert("RGB"))
217
+ # Video (T,H,W,3|4) uint8
218
+ if x.ndim == 4 and x.shape[-1] in (3, 4) and x.dtype == _np.uint8:
219
+ # Pass frames straight to the processor — no mp4 round-trip, no
220
+ # av/imageio needed. Drop alpha if present.
221
+ return ("video", x if x.shape[-1] == 3 else x[..., :3])
222
+ # Audio multichannel 2D float -> mono mixdown
223
+ if x.ndim == 2 and _np.issubdtype(x.dtype, _np.floating):
224
+ audio = x.mean(axis=0 if x.shape[0] <= 8 else 1).astype(_np.float32)
225
+ return ("audio", audio)
226
+
227
+ # torch.Tensor -> numpy and recurse
228
+ try:
229
+ import torch as _torch
230
+ except ImportError:
231
+ _torch = None
232
+ if _torch is not None and isinstance(x, _torch.Tensor):
233
+ return _resolve_input(x.detach().cpu().numpy())
234
+
235
+ # bytes / BytesIO / file-like
236
+ if isinstance(x, (bytes, bytearray)):
237
+ data = bytes(x)
238
+ elif isinstance(x, io.IOBase):
239
+ data = x.read()
240
+ else:
241
+ data = None
242
+
243
+ if data is not None:
244
+ kind = _sniff_media_type_bytes(data[:4096])
245
+ if kind == "image":
246
+ _PILImage = _pil_image()
247
+ return ("image", _PILImage.open(io.BytesIO(data)).convert("RGB"))
248
+ if kind == "svg":
249
+ return ("image", _svg_to_image(bytes(data)))
250
+ if kind in ("video", "audio"):
251
+ import tempfile as _tf
252
+ ext = ".mp4" if kind == "video" else ".wav"
253
+ tf = _tf.NamedTemporaryFile(suffix=ext, delete=False)
254
+ tf.write(data); tf.close()
255
+ return (kind, tf.name)
256
+ if kind == "pdf":
257
+ # pypdfium2 reads bytes directly — no temp file needed.
258
+ return ("pdf", bytes(data))
259
+
260
+ if isinstance(x, str):
261
+ local = _download_if_url(x)
262
+ if _os.path.isfile(local):
263
+ kind = _sniff_media_type(local)
264
+ if kind == "image":
265
+ _PILImage = _pil_image()
266
+ return ("image", _PILImage.open(local).convert("RGB"))
267
+ if kind == "svg":
268
+ return ("image", _svg_to_image(local))
269
+ if kind in ("video", "audio", "pdf"):
270
+ return (kind, local)
271
+ return ("text", x)
272
+
273
+ return ("text", str(x))
274
+
275
+
276
+ def _is_media_string(x) -> bool:
277
+ if not isinstance(x, str):
278
+ return False
279
+ return _resolve_input(x)[0] in ("image", "video", "audio", "pdf")
280
+
281
+
282
+ def _prompt_from_kwargs(st_model, kwargs):
283
+ prompt = kwargs.get("prompt")
284
+ if prompt is None:
285
+ prompt_name = kwargs.get("prompt_name") or getattr(st_model, "default_prompt_name", None)
286
+ prompt = (getattr(st_model, "prompts", {}) or {}).get(prompt_name, "") if prompt_name else ""
287
+ return prompt or ""
288
+
289
+
290
+ def _raw_media_parts(st_model, value, kwargs):
291
+ prompt = _prompt_from_kwargs(st_model, kwargs)
292
+ return (prompt, value) if prompt else (value,)
293
+
294
+
295
+ def _prompted_parts(st_model, value, kwargs):
296
+ parts = value if isinstance(value, tuple) else (value,)
297
+ prompt = _prompt_from_kwargs(st_model, kwargs)
298
+ return (prompt, *parts) if prompt else parts
299
+
300
+
301
+ def _align_eval_processor(processor):
302
+ video_processor = getattr(processor, "video_processor", None)
303
+ if video_processor is None:
304
+ return
305
+ if hasattr(video_processor, "do_sample_frames"):
306
+ video_processor.do_sample_frames = False
307
+ for attr in ("max_frames", "num_frames"):
308
+ if hasattr(video_processor, attr):
309
+ setattr(video_processor, attr, EVAL_VIDEO_NUM_FRAMES)
310
+ if hasattr(video_processor, "size") and isinstance(video_processor.size, dict):
311
+ video_processor.size = {
312
+ **video_processor.size,
313
+ "longest_edge": EVAL_VIDEO_MAX_PIXELS,
314
+ "shortest_edge": EVAL_IMAGE_MIN_PIXELS,
315
+ }
316
+ if hasattr(video_processor, "max_pixels"):
317
+ video_processor.max_pixels = EVAL_VIDEO_MAX_PIXELS
318
+ if hasattr(video_processor, "min_pixels"):
319
+ video_processor.min_pixels = EVAL_IMAGE_MIN_PIXELS
320
+
321
+
322
+ def _build_eval_image_prompt(processor, prefix: str = ""):
323
+ image_token = getattr(processor, "image_token", IMAGE_PROMPT)
324
+ text = f"{prefix or ''}<|vision_start|>{image_token}<|vision_end|>"
325
+ try:
326
+ return processor.apply_chat_template(
327
+ [{"role": "user", "content": text}],
328
+ tokenize=False,
329
+ add_generation_prompt=False,
330
+ )
331
+ except (ValueError, AttributeError):
332
+ return f"{prefix or ''}{IMAGE_PROMPT}"
333
+
334
+
335
+ def _audio_output_length(feature_attention_mask):
336
+ real_frames = feature_attention_mask.sum(-1)
337
+ aftercnn = (real_frames - 1) // 2 + 1
338
+ return int(((aftercnn - 2) // 2 + 1).item())
339
+
340
+
341
+ def _load_audio_array(audio_input):
342
+ import numpy as np
343
+
344
+ if isinstance(audio_input, _AudioWrapper):
345
+ return audio_input.array.astype(np.float32), audio_input.sampling_rate
346
+ if isinstance(audio_input, str):
347
+ try:
348
+ import librosa
349
+ except ImportError as e:
350
+ raise ImportError(
351
+ "Loading audio from a file path needs `pip install librosa`"
352
+ " (or pass a 1-D numpy float32 waveform at 16 kHz)."
353
+ ) from e
354
+ audio, sr = librosa.load(audio_input, sr=16000)
355
+ return audio.astype(np.float32), sr
356
+ if isinstance(audio_input, np.ndarray):
357
+ return audio_input.astype(np.float32), 16000
358
+ raise TypeError(f"Unsupported audio input type: {type(audio_input)}")
359
+
360
+
361
+ def _build_audio_model_inputs(owner, audio_input, device, prefix: str = ""):
362
+ import numpy as np
363
+ from transformers import WhisperFeatureExtractor
364
+
365
+ audio, sr = _load_audio_array(audio_input)
366
+ if not np.isfinite(audio).all():
367
+ audio = np.nan_to_num(audio, nan=0.0, posinf=0.0, neginf=0.0)
368
+ peak = float(np.max(np.abs(audio))) if audio.size else 0.0
369
+ if peak > 1.0:
370
+ audio = audio / peak
371
+
372
+ feat_ext = WhisperFeatureExtractor(feature_size=128)
373
+ audio_inputs = feat_ext(
374
+ audio,
375
+ sampling_rate=sr,
376
+ return_tensors="pt",
377
+ padding="max_length",
378
+ return_attention_mask=True,
379
+ )
380
+ input_features = audio_inputs["input_features"]
381
+ feature_attention_mask = audio_inputs["attention_mask"]
382
+ n_tokens = _audio_output_length(feature_attention_mask)
383
+
384
+ start = owner.tokenizer.convert_ids_to_tokens(owner.config.audio_start_token_id)
385
+ token = owner.tokenizer.convert_ids_to_tokens(owner.config.audio_token_id)
386
+ end = owner.tokenizer.convert_ids_to_tokens(owner.config.audio_end_token_id)
387
+ audio_seq = start + token * n_tokens + end
388
+ text = f"{prefix or ''}{audio_seq}"
389
+ try:
390
+ prompt = owner.processor.apply_chat_template(
391
+ [{"role": "user", "content": text}],
392
+ tokenize=False,
393
+ add_generation_prompt=False,
394
+ )
395
+ except (ValueError, AttributeError):
396
+ prompt = text
397
+
398
+ out = owner.processor(text=[prompt], return_tensors="pt", padding=False, truncation=False)
399
+ model_dtype = next(owner.model.parameters()).dtype
400
+ inputs = {k: v.to(device) for k, v in out.items() if torch.is_tensor(v)}
401
+ inputs["input_features"] = input_features.to(device=device, dtype=model_dtype)
402
+ inputs["feature_attention_mask"] = feature_attention_mask.to(device)
403
+ pos_builder = globals().get("_get_1d_position_ids")
404
+ if pos_builder is not None:
405
+ inputs["position_ids"] = pos_builder(inputs["attention_mask"])
406
+ return inputs
407
+
408
+
409
+ def _extract_audio_from_video(video_path):
410
+ """Return mono float32 audio @ 16 kHz decoded from the video's audio track, or
411
+ None if no audio stream is present. PyAV is already a dep for video decoding."""
412
+ try:
413
+ import av
414
+ import numpy as np
415
+ from av.audio.resampler import AudioResampler
416
+ except ImportError:
417
+ return None
418
+ container = av.open(video_path)
419
+ try:
420
+ audio_stream = next((s for s in container.streams if s.type == "audio"), None)
421
+ if audio_stream is None:
422
+ return None
423
+ resampler = AudioResampler(format="flt", layout="mono", rate=16000)
424
+ samples = []
425
+ for frame in container.decode(audio=0):
426
+ for rf in resampler.resample(frame):
427
+ samples.append(rf.to_ndarray().flatten())
428
+ for rf in resampler.resample(None):
429
+ samples.append(rf.to_ndarray().flatten())
430
+ if not samples:
431
+ return None
432
+ return np.concatenate(samples).astype(np.float32)
433
+ finally:
434
+ container.close()
435
+
436
+
437
+ def _eval_video_frames(video_path):
438
+ if not isinstance(video_path, str):
439
+ return video_path
440
+ try:
441
+ import av
442
+ import numpy as np
443
+ except ImportError:
444
+ return video_path
445
+ container = av.open(video_path)
446
+ try:
447
+ frames = [frame.to_image().convert("RGB") for frame in container.decode(video=0)]
448
+ finally:
449
+ container.close()
450
+ if not frames:
451
+ return video_path
452
+ if len(frames) <= EVAL_VIDEO_NUM_FRAMES:
453
+ return frames
454
+ indices = np.linspace(0, len(frames) - 1, EVAL_VIDEO_NUM_FRAMES, dtype=int).tolist()
455
+ return [frames[i] for i in indices]
456
+
457
+
458
+ def _pdf_to_images(pdf, dpi: int = PDF_DPI):
459
+ """Rasterise every page of a PDF to a list of PIL.Image (RGB).
460
+
461
+ `pdf` may be a path, raw bytes, BytesIO, or an existing list of PIL.Images
462
+ (returned as-is). Lazy-imports `pypdfium2` so users who never touch PDFs
463
+ are not forced to install it.
464
+ """
465
+ _PILImage = _pil_image() # PIL is a hard dep of the image path
466
+ if isinstance(pdf, list) and pdf and all(isinstance(p, _PILImage.Image) for p in pdf):
467
+ return pdf
468
+ try:
469
+ import pypdfium2 as pdfium
470
+ except ImportError as e:
471
+ raise ImportError(
472
+ "Decoding PDF pages needs `pip install pypdfium2`."
473
+ ) from e
474
+ import io as _io
475
+ if isinstance(pdf, (bytes, bytearray)):
476
+ doc = pdfium.PdfDocument(bytes(pdf))
477
+ elif isinstance(pdf, _io.IOBase):
478
+ doc = pdfium.PdfDocument(pdf.read())
479
+ else:
480
+ doc = pdfium.PdfDocument(pdf)
481
+ scale = dpi / 72.0
482
+ pages = []
483
+ try:
484
+ for page in doc:
485
+ pil = page.render(scale=scale).to_pil().convert("RGB")
486
+ pages.append(pil)
487
+ finally:
488
+ doc.close()
489
+ return pages
490
+
491
+
492
+ def _patch_st_encode_multipart():
493
+ """Intercept ST.encode for multipart tuple inputs so PIL.Image and
494
+ np.ndarray media parts bypass ST's length-sort."""
495
+ import importlib
496
+ import torch
497
+ try:
498
+ st_mod = importlib.import_module("sentence_transformers.SentenceTransformer")
499
+ except ImportError:
500
+ return
501
+ _ST = st_mod.SentenceTransformer
502
+ if getattr(_ST.encode, "_omni_multipart_patched", False):
503
+ return
504
+ _orig = _ST.encode
505
+
506
+ def _encode(self, sentences, *args, **kwargs):
507
+ def _is_nonstring_input(x):
508
+ # anything other than a pure string becomes a 1-part multipart item
509
+ return not isinstance(x, str)
510
+ single_bare = _is_nonstring_input(sentences) and not isinstance(sentences, list)
511
+ list_with_nonstr = (isinstance(sentences, list) and sentences
512
+ and any(_is_nonstring_input(s) for s in sentences))
513
+ single_media_string = isinstance(sentences, str) and _is_media_string(sentences)
514
+ list_with_media_string = (isinstance(sentences, list) and sentences
515
+ and any(isinstance(s, str) and _is_media_string(s) for s in sentences))
516
+ fwd_keys = getattr(self[0], "forward_kwargs", set())
517
+ forward_kwargs = {k: kwargs[k] for k in fwd_keys if k in kwargs}
518
+ if single_media_string or list_with_media_string:
519
+ if single_media_string:
520
+ batch = [_raw_media_parts(self, sentences, kwargs)]
521
+ else:
522
+ batch = [_raw_media_parts(self, s, kwargs) for s in sentences]
523
+ features = {"_multipart_batch": batch, "_is_multipart_batch": True}
524
+ with torch.no_grad():
525
+ out = self[0](features, **forward_kwargs)
526
+ emb = out["sentence_embedding"]
527
+ if kwargs.get("convert_to_numpy", True):
528
+ emb = emb.detach().cpu().float().numpy()
529
+ if single_media_string:
530
+ emb = emb[0] if hasattr(emb, "__getitem__") else emb
531
+ return emb
532
+ if single_bare or list_with_nonstr:
533
+ if single_bare:
534
+ batch = [_prompted_parts(self, sentences, kwargs)]
535
+ else:
536
+ batch = [_prompted_parts(self, s, kwargs) for s in sentences]
537
+ features = {"_multipart_batch": batch, "_is_multipart_batch": True}
538
+ with torch.no_grad():
539
+ out = self[0](features, **forward_kwargs)
540
+ emb = out["sentence_embedding"]
541
+ if kwargs.get("convert_to_numpy", True):
542
+ emb = emb.detach().cpu().float().numpy()
543
+ if single_bare:
544
+ emb = emb[0] if hasattr(emb, "__getitem__") else emb
545
+ return emb
546
+ result = _orig(self, sentences, *args, **kwargs)
547
+ # ST 5.x applies truncate_dim without L2 renormalization; the README
548
+ # promises unit-norm truncated embeddings, so restore that here.
549
+ if kwargs.get("truncate_dim") is not None and not kwargs.get("normalize_embeddings", False):
550
+ import numpy as _np
551
+ if torch.is_tensor(result):
552
+ result = torch.nn.functional.normalize(result, p=2, dim=-1)
553
+ elif isinstance(result, _np.ndarray):
554
+ n = _np.linalg.norm(result, axis=-1, keepdims=True) + 1e-12
555
+ result = result / n
556
+ return result
557
+
558
+ _encode._omni_multipart_patched = True
559
+ _ST.encode = _encode
560
+
561
+ def encode_query(self, sentences, *args, **kwargs):
562
+ kwargs.setdefault("prompt_name", "query")
563
+ return self.encode(sentences, *args, **kwargs)
564
+
565
+ def encode_document(self, sentences, *args, **kwargs):
566
+ kwargs.setdefault("prompt_name", "document")
567
+ return self.encode(sentences, *args, **kwargs)
568
+
569
+ _ST.encode_query = encode_query
570
+ _ST.encode_document = encode_document
571
+
572
+
573
+ _patch_st_encode_multipart()
574
+
575
+
576
+ class Transformer(nn.Module):
577
+ save_in_root: bool = True
578
+ # Tells sentence-transformers to thread these kwargs from encode() through
579
+ # to our forward() — otherwise ST filters unknown kwargs out.
580
+ forward_kwargs = {"task", "truncate_dim"}
581
+
582
+ def __init__(
583
+ self,
584
+ model_name_or_path: str = "jinaai/jina-embeddings-v5-omni-nano",
585
+ max_seq_length: Optional[int] = None,
586
+ config_args: Optional[Dict[str, Any]] = None,
587
+ model_args: Optional[Dict[str, Any]] = None,
588
+ tokenizer_args: Optional[Dict[str, Any]] = None,
589
+ cache_dir: Optional[str] = None,
590
+ backend: str = "torch",
591
+ task: Optional[str] = None,
592
+ default_task: Optional[str] = None,
593
+ **kwargs,
594
+ ) -> None:
595
+ super().__init__()
596
+ if backend != "torch":
597
+ raise ValueError(
598
+ f"Backend '{backend}' is not supported, please use 'torch' instead"
599
+ )
600
+
601
+ config_kwargs = dict(config_args or {})
602
+ model_kwargs = dict(model_args or {})
603
+ tokenizer_kwargs = dict(tokenizer_args or {})
604
+
605
+ # Default-task resolution precedence (highest to lowest):
606
+ # 1. `task` / `default_task` kwarg to this __init__
607
+ # 2. `model_args={'default_task': ...}` (legacy path)
608
+ # 3. JINA_V5_TASK env var
609
+ # 4. unset -> encode() must pass task=
610
+ self.default_task = (
611
+ task
612
+ or default_task
613
+ or model_kwargs.pop("default_task", None)
614
+ or os.environ.get("JINA_V5_TASK")
615
+ )
616
+ if self.default_task and self.default_task not in TASK_NAMES:
617
+ raise ValueError(
618
+ f"Invalid task: {self.default_task}. Must be one of {TASK_NAMES}."
619
+ )
620
+
621
+ # setdefault so caller-provided trust_remote_code isn't duplicated
622
+ config_kwargs.setdefault("trust_remote_code", True)
623
+ model_kwargs.setdefault("trust_remote_code", True)
624
+ tokenizer_kwargs.setdefault("trust_remote_code", True)
625
+ # Dedupe cache_dir: we pass it explicitly below, so strip any copy
626
+ # that sentence-transformers may have also threaded through *_args.
627
+ for _kw in (config_kwargs, model_kwargs, tokenizer_kwargs):
628
+ _kw.pop("cache_dir", None)
629
+
630
+ self.config = AutoConfig.from_pretrained(
631
+ model_name_or_path, cache_dir=cache_dir, **config_kwargs
632
+ )
633
+ self.model = AutoModel.from_pretrained(
634
+ model_name_or_path, cache_dir=cache_dir, **model_kwargs,
635
+ )
636
+ self.tokenizer = self.model.tokenizer
637
+ # AutoProcessor pulls in PIL transitively; lazy-import so users on
638
+ # text-only setups (no pillow installed) can still load the model.
639
+ try:
640
+ from transformers import AutoProcessor as _AutoProcessor
641
+ processor_kwargs = dict(tokenizer_kwargs)
642
+ processor_kwargs.setdefault("min_pixels", EVAL_IMAGE_MIN_PIXELS)
643
+ processor_kwargs.setdefault("max_pixels", EVAL_IMAGE_MAX_PIXELS)
644
+ self.processor = _AutoProcessor.from_pretrained(
645
+ model_name_or_path, cache_dir=cache_dir, **processor_kwargs,
646
+ )
647
+ _align_eval_processor(self.processor)
648
+ except Exception:
649
+ self.processor = None
650
+
651
+ tc = getattr(self.config, "text_config", self.config)
652
+ max_pos = getattr(tc, "max_position_embeddings", MAX_SEQ_LENGTH)
653
+ self.max_seq_length = max_seq_length or min(max_pos, MAX_SEQ_LENGTH)
654
+
655
+ def tokenize(
656
+ self,
657
+ texts: Union[List[str], List[Dict], list],
658
+ padding: Union[str, bool] = True,
659
+ **kwargs,
660
+ ) -> Dict[str, torch.Tensor]:
661
+ if texts and any(isinstance(t, tuple) for t in texts):
662
+ # Wrap non-tuple entries as 1-tuples so every batch slot goes
663
+ # through _encode_parts. Lets users mix: [(t,img), "plain text"].
664
+ wrapped = [t if isinstance(t, tuple) else (t,) for t in texts]
665
+ return {"_multipart_batch": wrapped, "_is_multipart_batch": True}
666
+ resolved = [_resolve_input(t) for t in texts]
667
+ # Heterogeneous batch (e.g. ["speech.wav", "plain text"]) — route through
668
+ # the multipart path where each element is dispatched on its own kind.
669
+ if len({k for k, _ in resolved}) > 1:
670
+ wrapped = [t if isinstance(t, tuple) else (t,) for t in texts]
671
+ return {"_multipart_batch": wrapped, "_is_multipart_batch": True}
672
+ first_kind = resolved[0][0]
673
+ values = [v for _, v in resolved]
674
+
675
+ if first_kind == "image":
676
+ return {"_images": values, "_is_image_batch": True}
677
+ if first_kind == "video":
678
+ return {"_video_paths": values, "_is_video_batch": True}
679
+ if first_kind == "audio":
680
+ return {"_audio_paths": values, "_is_audio_batch": True}
681
+ if first_kind == "pdf":
682
+ return {"_pdfs": values, "_is_pdf_batch": True}
683
+
684
+ if isinstance(texts[0], dict):
685
+ texts = [next(iter(t.values())) for t in texts]
686
+ elif isinstance(texts[0], (list, tuple)):
687
+ texts = [t[0] for t in texts]
688
+
689
+ return self.tokenizer(
690
+ [str(s) for s in texts],
691
+ max_length=self.max_seq_length,
692
+ truncation=True,
693
+ padding=padding,
694
+ return_tensors="pt",
695
+ )
696
+
697
+ def _resolve_task(self, task: Optional[str]) -> str:
698
+ if task is None:
699
+ if self.default_task is None:
700
+ raise ValueError(
701
+ "Task must be specified. Set it during loading "
702
+ "(model_kwargs={'default_task': 'retrieval'}) or pass "
703
+ "task='retrieval' to encode()."
704
+ )
705
+ task = self.default_task
706
+ if task not in TASK_NAMES:
707
+ raise ValueError(f"Invalid task: {task}. Must be one of {TASK_NAMES}.")
708
+ return task
709
+
710
+ def _last_token_pool(self, hidden, attention_mask):
711
+ seq_lens = attention_mask.sum(dim=1) - 1
712
+ pooled = hidden[torch.arange(hidden.shape[0], device=hidden.device), seq_lens]
713
+ return F.normalize(pooled, p=2, dim=-1).float()
714
+
715
+ def _encode_single_image(self, image, device, prefix: str = "") -> torch.Tensor:
716
+ prompt = _build_eval_image_prompt(self.processor, prefix=prefix)
717
+ inputs = self.processor(images=image, text=prompt, return_tensors="pt", truncation=False)
718
+ inputs = {k: v.to(device) for k, v in inputs.items() if torch.is_tensor(v)}
719
+ with torch.no_grad():
720
+ hidden = self.model(**inputs).last_hidden_state
721
+ return self._last_token_pool(hidden, inputs["attention_mask"]).squeeze(0)
722
+
723
+ def _encode_single_video(self, video_path, device) -> torch.Tensor:
724
+ video = _eval_video_frames(video_path)
725
+ inputs = self.processor(videos=video, text=VIDEO_PROMPT, return_tensors="pt", truncation=False)
726
+ inputs = {k: v.to(device) for k, v in inputs.items() if torch.is_tensor(v)}
727
+ with torch.no_grad():
728
+ hidden = self.model(**inputs).last_hidden_state
729
+ return self._last_token_pool(hidden, inputs["attention_mask"]).squeeze(0)
730
+
731
+ def _encode_single_audio(self, audio_input, device, prefix: str = "") -> torch.Tensor:
732
+ inputs = _build_audio_model_inputs(self, audio_input, device, prefix=prefix)
733
+ with torch.no_grad():
734
+ hidden = self.model(**inputs).last_hidden_state
735
+ return self._last_token_pool(hidden, inputs["attention_mask"]).squeeze(0)
736
+
737
+ def _encode_single_pdf(self, pdf, device) -> torch.Tensor:
738
+ """Encode a PDF as a fused sequence of page images (single embedding).
739
+
740
+ Pages are rasterised with pypdfium2 then fed through the same
741
+ multipart fusion path used for tuples — so a 3-page PDF produces
742
+ a single embedding spanning all three rendered pages.
743
+ """
744
+ pages = _pdf_to_images(pdf)
745
+ if not pages:
746
+ raise ValueError("PDF has 0 pages — nothing to encode.")
747
+ return self._encode_parts(tuple(pages), device)
748
+
749
+ def _encode_composite_parts(self, expanded, device) -> torch.Tensor:
750
+ import numpy as np
751
+ from transformers import WhisperFeatureExtractor
752
+
753
+ content = []
754
+ images, videos = [], []
755
+ audio_features, feature_masks = [], []
756
+ feat_ext = None
757
+ for kind, p in expanded:
758
+ if kind == "text":
759
+ content.append({"type": "text", "text": str(p)})
760
+ elif kind == "image":
761
+ content.append({"type": "image"})
762
+ images.append(p)
763
+ elif kind == "video":
764
+ content.append({"type": "video"})
765
+ videos.append(_eval_video_frames(p) if isinstance(p, str) else p)
766
+ elif kind == "audio":
767
+ if feat_ext is None:
768
+ feat_ext = WhisperFeatureExtractor(feature_size=128)
769
+ audio_arr, sr = _load_audio_array(p)
770
+ if not np.isfinite(audio_arr).all():
771
+ audio_arr = np.nan_to_num(audio_arr, nan=0.0, posinf=0.0, neginf=0.0)
772
+ peak = float(np.max(np.abs(audio_arr))) if audio_arr.size else 0.0
773
+ if peak > 1.0:
774
+ audio_arr = audio_arr / peak
775
+ audio_inputs = feat_ext(
776
+ audio_arr,
777
+ sampling_rate=sr,
778
+ return_tensors="pt",
779
+ padding="max_length",
780
+ return_attention_mask=True,
781
+ )
782
+ feat_mask = audio_inputs["attention_mask"]
783
+ n_tokens = _audio_output_length(feat_mask)
784
+ start = self.tokenizer.convert_ids_to_tokens(self.config.audio_start_token_id)
785
+ token = self.tokenizer.convert_ids_to_tokens(self.config.audio_token_id)
786
+ end = self.tokenizer.convert_ids_to_tokens(self.config.audio_end_token_id)
787
+ content.append({"type": "text", "text": start + token * n_tokens + end})
788
+ audio_features.append(audio_inputs["input_features"])
789
+ feature_masks.append(feat_mask)
790
+
791
+ has_chat_template = getattr(self.processor, "chat_template", None) is not None
792
+ if has_chat_template:
793
+ prompt = self.processor.apply_chat_template(
794
+ [{"role": "user", "content": content}],
795
+ tokenize=False,
796
+ add_generation_prompt=False,
797
+ )
798
+ if images or videos:
799
+ image_token = getattr(self.processor, "image_token", "<|image_pad|>")
800
+ video_token = getattr(self.processor, "video_token", "<|video_pad|>")
801
+ flat = []
802
+ for c in content:
803
+ if c.get("type") == "text":
804
+ flat.append(c["text"])
805
+ elif c.get("type") == "image":
806
+ flat.append(f"<|vision_start|>{image_token}<|vision_end|>")
807
+ elif c.get("type") == "video":
808
+ flat.append(f"<|vision_start|>{video_token}<|vision_end|>")
809
+ prompt_flat = self.processor.apply_chat_template(
810
+ [{"role": "user", "content": "".join(flat)}],
811
+ tokenize=False,
812
+ add_generation_prompt=False,
813
+ )
814
+ if "<|vision_start|>" in prompt_flat:
815
+ prompt = prompt_flat
816
+ else:
817
+ pieces = []
818
+ for c in content:
819
+ if c.get("type") == "text":
820
+ pieces.append(c["text"])
821
+ elif c.get("type") == "image":
822
+ pieces.append(IMAGE_PROMPT)
823
+ elif c.get("type") == "video":
824
+ pieces.append(VIDEO_PROMPT)
825
+ prompt = "".join(pieces)
826
+
827
+ proc_kwargs = {"text": [prompt], "return_tensors": "pt", "padding": False, "truncation": False}
828
+ if images:
829
+ proc_kwargs["images"] = images
830
+ if videos:
831
+ proc_kwargs["videos"] = videos
832
+ out = self.processor(**proc_kwargs)
833
+ model_dtype = next(self.model.parameters()).dtype
834
+ inputs = {k: v.to(device) if torch.is_tensor(v) else v for k, v in out.items()}
835
+ if audio_features:
836
+ inputs["input_features"] = torch.cat(audio_features, dim=0).to(device=device, dtype=model_dtype)
837
+ inputs["feature_attention_mask"] = torch.cat(feature_masks, dim=0).to(device)
838
+
839
+ if "Qwen" in type(self.processor).__name__:
840
+ ids = inputs["input_ids"].squeeze(0)
841
+ mm_ids = torch.zeros_like(ids, dtype=torch.int32)
842
+ image_token_id = self.processor.tokenizer.convert_tokens_to_ids(getattr(self.processor, "image_token", "<image>"))
843
+ video_token_id = self.processor.tokenizer.convert_tokens_to_ids(getattr(self.processor, "video_token", "<video>"))
844
+ audio_token_id = self.processor.tokenizer.convert_tokens_to_ids(self.tokenizer.convert_ids_to_tokens(self.config.audio_token_id))
845
+ mm_ids += (ids == image_token_id).to(torch.int32)
846
+ mm_ids += 2 * (ids == video_token_id).to(torch.int32)
847
+ mm_ids += 3 * (ids == audio_token_id).to(torch.int32)
848
+ inputs["mm_token_type_ids"] = mm_ids.unsqueeze(0)
849
+ mask = inputs["attention_mask"]
850
+ pos = mask.long().cumsum(-1) - 1
851
+ pos = pos.masked_fill(mask == 0, 0)
852
+ inputs["position_ids"] = pos.unsqueeze(0).expand(3, -1, -1).contiguous()
853
+ else:
854
+ pos_builder = globals().get("_get_1d_position_ids")
855
+ if pos_builder is not None:
856
+ inputs["position_ids"] = pos_builder(inputs["attention_mask"])
857
+
858
+ with torch.no_grad():
859
+ hidden = self.model(**inputs).last_hidden_state
860
+ return self._last_token_pool(hidden, inputs["attention_mask"]).squeeze(0)
861
+
862
+ def _encode_parts(self, parts, device) -> torch.Tensor:
863
+ """Fuse a tuple of parts into one embedding in a single forward pass.
864
+
865
+ Each part may be a URL, a local path (sniffed by magic bytes if no
866
+ extension), a PIL.Image, a 1-D numpy audio array, a PDF (rasterised
867
+ to one image per page), or plain text. A video with an audio track
868
+ is auto-expanded to [extracted_audio, video] so the audio tokens
869
+ precede the video tokens.
870
+ """
871
+ import numpy as np
872
+ from transformers import WhisperFeatureExtractor
873
+
874
+ # Normalize every part first (URL -> path, content-sniff if needed).
875
+ resolved = [_resolve_input(p) for p in parts]
876
+
877
+ # Expand videos-with-audio: prepend extracted audio.
878
+ # Expand PDFs: rasterise into one image-part per page.
879
+ expanded = []
880
+ for kind, value in resolved:
881
+ if kind == "video":
882
+ if isinstance(value, str):
883
+ aud = _extract_audio_from_video(value)
884
+ if aud is not None and aud.size > 0:
885
+ expanded.append(("audio", aud))
886
+ expanded.append(("video", value))
887
+ elif kind == "pdf":
888
+ for page in _pdf_to_images(value):
889
+ expanded.append(("image", page))
890
+ else:
891
+ expanded.append((kind, value))
892
+
893
+ ids_chunks, mask_chunks = [], []
894
+ pix_images, img_grid = [], []
895
+ pix_videos, vid_grid = [], []
896
+ audio_features = []
897
+ feat_ext = None
898
+
899
+ if len(expanded) == 1 and expanded[0][0] == "image":
900
+ return self._encode_single_image(expanded[0][1], device)
901
+ if len(expanded) == 1 and expanded[0][0] == "audio":
902
+ return self._encode_single_audio(expanded[0][1], device)
903
+ if len(expanded) == 2 and expanded[0][0] == "text" and expanded[1][0] == "image":
904
+ return self._encode_single_image(expanded[1][1], device, prefix=str(expanded[0][1]))
905
+ if len(expanded) == 2 and expanded[0][0] == "text" and expanded[1][0] == "audio":
906
+ return self._encode_single_audio(expanded[1][1], device, prefix=str(expanded[0][1]))
907
+
908
+ return self._encode_composite_parts(expanded, device)
909
+
910
+ def forward(
911
+ self,
912
+ features: Dict[str, torch.Tensor],
913
+ task: Optional[str] = None,
914
+ truncate_dim: Optional[int] = None,
915
+ **kwargs,
916
+ ) -> Dict[str, torch.Tensor]:
917
+ self.model.eval()
918
+ device = next(self.model.parameters()).device
919
+ task = self._resolve_task(task)
920
+ self.model.set_adapter([task])
921
+
922
+ if features.get("_is_multipart_batch"):
923
+ embs = [self._encode_parts(parts, device) for parts in features["_multipart_batch"]]
924
+ features["sentence_embedding"] = torch.stack(embs)
925
+ return self._maybe_truncate(features, truncate_dim)
926
+
927
+ if features.get("_is_image_batch"):
928
+ embs = [self._encode_single_image(img, device) for img in features["_images"]]
929
+ features["sentence_embedding"] = torch.stack(embs)
930
+ return self._maybe_truncate(features, truncate_dim)
931
+
932
+ if features.get("_is_video_batch"):
933
+ embs = [self._encode_single_video(p, device) for p in features["_video_paths"]]
934
+ features["sentence_embedding"] = torch.stack(embs)
935
+ return self._maybe_truncate(features, truncate_dim)
936
+
937
+ if features.get("_is_audio_batch"):
938
+ embs = [self._encode_single_audio(p, device) for p in features["_audio_paths"]]
939
+ features["sentence_embedding"] = torch.stack(embs)
940
+ return self._maybe_truncate(features, truncate_dim)
941
+
942
+ if features.get("_is_pdf_batch"):
943
+ embs = [self._encode_single_pdf(p, device) for p in features["_pdfs"]]
944
+ features["sentence_embedding"] = torch.stack(embs)
945
+ return self._maybe_truncate(features, truncate_dim)
946
+
947
+ batch = {k: v.to(device) for k, v in features.items() if torch.is_tensor(v)}
948
+ with torch.no_grad():
949
+ hidden = self.model(**batch).last_hidden_state
950
+
951
+ features["sentence_embedding"] = self._last_token_pool(hidden, batch["attention_mask"])
952
+ return self._maybe_truncate(features, truncate_dim)
953
+
954
+ @staticmethod
955
+ def _maybe_truncate(features, truncate_dim):
956
+ # Slicing an L2-normalized vector and renormalizing is equivalent to
957
+ # truncate-then-normalize on the raw pooled vector — so this produces a
958
+ # unit-norm matryoshka embedding.
959
+ if truncate_dim is not None:
960
+ emb = features["sentence_embedding"][..., :truncate_dim]
961
+ features["sentence_embedding"] = F.normalize(emb, p=2, dim=-1)
962
+ return features
963
+
964
+ def get_word_embedding_dimension(self) -> int:
965
+ tc = getattr(self.config, "text_config", self.config)
966
+ return getattr(tc, "hidden_size", 768)
967
+
968
+ def get_sentence_embedding_dimension(self) -> int:
969
+ return self.get_word_embedding_dimension()
970
+
971
+ def get_max_seq_length(self) -> int:
972
+ return self.max_seq_length
973
+
974
+ def save(self, output_path: str, safe_serialization: bool = True, **kwargs) -> None:
975
+ self.model.save_pretrained(output_path, safe_serialization=safe_serialization)
976
+ self.tokenizer.save_pretrained(output_path)
977
+ config = {"max_seq_length": self.max_seq_length}
978
+ with open(os.path.join(output_path, "sentence_bert_config.json"), "w") as f:
979
+ json.dump(config, f, indent=2)
980
+
981
+ @classmethod
982
+ def load(cls, input_path: str) -> "Transformer":
983
+ # Signature must have exactly 1 param so ST routes through the direct
984
+ # constructor path (which maps model_kwargs -> model_args correctly).
985
+ config_path = os.path.join(input_path, "sentence_bert_config.json")
986
+ extra = {}
987
+ if os.path.exists(config_path):
988
+ with open(config_path) as f:
989
+ extra = json.load(f)
990
+ return cls(model_name_or_path=input_path, **extra)
jina_v5_omni_tokenizer/modeling_jina_embeddings_v5_omni.py ADDED
@@ -0,0 +1,625 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Unified jina-embeddings-v5-omni-nano: vision + audio + text with task-specific routing.
3
+
4
+ Shared: Qwen3VLVisionModel + Qwen2.5-Omni audio encoder + LlamaModel (EuroBERT, bidirectional)
5
+ Per-task: vision merger, audio projector, special token embeddings, LoRA adapter
6
+
7
+ Modality loading:
8
+ model = AutoModel.from_pretrained(path, trust_remote_code=True) # all components (default)
9
+ model = AutoModel.from_pretrained(path, trust_remote_code=True, modality="vision") # no audio tower/projectors
10
+ model = AutoModel.from_pretrained(path, trust_remote_code=True, modality="audio") # no vision tower/mergers
11
+
12
+ Usage:
13
+ model = AutoModel.from_pretrained("jinaai/jina-embeddings-v5-omni-nano", trust_remote_code=True)
14
+ embeddings = model.encode(["hello world"], task="retrieval")
15
+ """
16
+
17
+ from typing import List, Optional
18
+ import os
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+ from huggingface_hub import snapshot_download
25
+ from transformers import AutoTokenizer, LlamaConfig, PreTrainedModel, PretrainedConfig
26
+ from transformers.modeling_outputs import BaseModelOutputWithPast
27
+ from transformers.models.llama.modeling_llama import LlamaModel
28
+ from transformers.models.qwen3_vl.configuration_qwen3_vl import Qwen3VLVisionConfig
29
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLVisionModel
30
+ from transformers.models.qwen2_5_omni.configuration_qwen2_5_omni import Qwen2_5OmniAudioEncoderConfig
31
+ from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
32
+ from peft import PeftMixedModel, PeftConfig
33
+
34
+ TASK_NAMES = ["retrieval", "text-matching", "clustering", "classification"]
35
+ _VALID_MODALITIES = ("omni", "vision", "audio", "text")
36
+
37
+
38
+ def _key(task):
39
+ return task.replace("-", "_")
40
+
41
+
42
+ class PretrainedMerger(nn.Module):
43
+ def __init__(self, hidden_size, out_hidden_size, spatial_merge_size=2):
44
+ super().__init__()
45
+ self.hidden_size = hidden_size * (spatial_merge_size ** 2)
46
+ self.norm = nn.LayerNorm(hidden_size, eps=1e-6)
47
+ self.linear_fc1 = nn.Linear(self.hidden_size, self.hidden_size)
48
+ self.act = nn.GELU()
49
+ self.linear_fc2 = nn.Linear(self.hidden_size, out_hidden_size)
50
+
51
+ def forward(self, x):
52
+ x = self.norm(x)
53
+ x = x.view(-1, self.hidden_size)
54
+ x = self.linear_fc2(self.act(self.linear_fc1(x)))
55
+ return x
56
+
57
+
58
+ class JinaEmbeddingsV5OmniConfig(PretrainedConfig):
59
+ model_type = "jina_embeddings_v5_omni"
60
+
61
+ def __init__(
62
+ self,
63
+ vision_config=None,
64
+ text_config=None,
65
+ audio_config=None,
66
+ task_names=None,
67
+ special_token_ids=None,
68
+ image_token_index=None,
69
+ audio_token_id=None,
70
+ audio_start_token_id=None,
71
+ audio_end_token_id=None,
72
+ projector_hidden_act="gelu",
73
+ tie_word_embeddings=False,
74
+ modality="omni",
75
+ **kwargs,
76
+ ):
77
+ if isinstance(vision_config, dict):
78
+ vision_config = PretrainedConfig(**vision_config)
79
+ self.vision_config = vision_config or PretrainedConfig()
80
+ if isinstance(text_config, dict):
81
+ text_config = PretrainedConfig(**text_config)
82
+ self.text_config = text_config or PretrainedConfig()
83
+ if isinstance(audio_config, dict):
84
+ audio_config = PretrainedConfig(**audio_config)
85
+ self.audio_config = audio_config or PretrainedConfig()
86
+ self.task_names = task_names or TASK_NAMES
87
+ self.special_token_ids = special_token_ids or []
88
+ self.image_token_index = image_token_index
89
+ self.audio_token_id = audio_token_id
90
+ self.audio_start_token_id = audio_start_token_id
91
+ self.audio_end_token_id = audio_end_token_id
92
+ self.projector_hidden_act = projector_hidden_act
93
+ if modality not in _VALID_MODALITIES:
94
+ raise ValueError(f"modality must be one of {_VALID_MODALITIES}, got '{modality}'")
95
+ self.modality = modality
96
+ super().__init__(tie_word_embeddings=tie_word_embeddings, **kwargs)
97
+
98
+ def get_text_config(self, **kwargs):
99
+ return self.text_config
100
+
101
+
102
+ class JinaEmbeddingsV5OmniBase(PreTrainedModel):
103
+ config_class = JinaEmbeddingsV5OmniConfig
104
+ supports_gradient_checkpointing = True
105
+ _supports_sdpa = True
106
+ _supports_flash_attn_2 = True
107
+ _supports_flash_attn = True
108
+ _supports_attention_backend = True
109
+ _tied_weights_keys = []
110
+ _keys_to_ignore_on_load_missing = ["lm_head.weight"]
111
+ _keys_to_ignore_on_load_unexpected = []
112
+
113
+ def __init__(self, config: JinaEmbeddingsV5OmniConfig):
114
+ super().__init__(config)
115
+
116
+ modality = getattr(config, "modality", "omni")
117
+ if modality not in _VALID_MODALITIES:
118
+ raise ValueError(f"modality must be one of {_VALID_MODALITIES}, got '{modality}'")
119
+ self._modality = modality
120
+ # propagate attn_implementation into the inner towers. The EuroBERT
121
+ # text encoder is bidirectional/non-causal; flash-attn varlen does not
122
+ # support it, so the text tower is kept on sdpa.
123
+ attn_impl = self.config._attn_implementation
124
+ text_attn_impl = "sdpa" if (attn_impl and "flash" in str(attn_impl)) else attn_impl
125
+
126
+ vision_cfg = config.vision_config
127
+ if not isinstance(vision_cfg, Qwen3VLVisionConfig):
128
+ d = vision_cfg.to_dict() if hasattr(vision_cfg, "to_dict") else dict(vision_cfg)
129
+ d.pop("model_type", None)
130
+ d.pop("transformers_version", None)
131
+ vision_cfg = Qwen3VLVisionConfig(**d)
132
+ vision_cfg.deepstack_visual_indexes = []
133
+ vision_cfg._attn_implementation = attn_impl
134
+
135
+ spatial_merge_size = getattr(vision_cfg, "spatial_merge_size", 2)
136
+ self._spatial_merge_size = spatial_merge_size
137
+ self._vision_hidden_size = vision_cfg.hidden_size
138
+
139
+ text_cfg = config.text_config
140
+ txt_dict = text_cfg.to_dict() if hasattr(text_cfg, "to_dict") else text_cfg
141
+ if not isinstance(text_cfg, LlamaConfig):
142
+ text_cfg = LlamaConfig(**txt_dict)
143
+ text_hidden = text_cfg.hidden_size
144
+
145
+ if modality not in ("audio", "text"):
146
+ self.vision_tower = Qwen3VLVisionModel(vision_cfg)
147
+ self.vision_tower.merger = nn.Identity()
148
+ self.vision_tower.deepstack_merger_list = nn.ModuleList()
149
+ self.vision_tower.deepstack_visual_indexes = []
150
+ self.mergers = nn.ModuleDict({
151
+ _key(t): PretrainedMerger(vision_cfg.hidden_size, text_hidden, spatial_merge_size)
152
+ for t in config.task_names
153
+ })
154
+
155
+ text_cfg._attn_implementation = text_attn_impl
156
+ self.language_model = LlamaModel(text_cfg)
157
+ for layer in self.language_model.layers:
158
+ layer.self_attn.is_causal = False
159
+
160
+ self.multi_modal_projector = nn.Identity()
161
+ self.lm_head = nn.Identity()
162
+
163
+ if modality not in ("vision", "text"):
164
+ aud_cfg = config.audio_config
165
+ aud_dict = aud_cfg.to_dict() if hasattr(aud_cfg, "to_dict") else aud_cfg
166
+ audio_encoder_config = Qwen2_5OmniAudioEncoderConfig(**aud_dict)
167
+ audio_encoder_config._attn_implementation = attn_impl
168
+ self.audio_tower = Qwen2_5OmniAudioEncoder(audio_encoder_config)
169
+ self.audio_tower.proj = nn.Identity() # fused into audio_projector(s)
170
+ output_dim = aud_dict.get('d_model', 1280) # fused: audio_projector(s) now take d_model
171
+ self.audio_projectors = nn.ModuleDict({
172
+ _key(t): nn.Linear(output_dim, text_hidden) for t in config.task_names
173
+ })
174
+
175
+ ignore = []
176
+ if modality in ("audio", "text"):
177
+ ignore.extend([r"^vision_tower\.", r"^mergers\."])
178
+ if modality in ("vision", "text"):
179
+ ignore.extend([r"^audio_tower\.", r"^audio_projectors\."])
180
+ if ignore:
181
+ self._keys_to_ignore_on_load_unexpected = ignore
182
+
183
+ n_special = len(config.special_token_ids)
184
+ self.task_token_embeddings = nn.ParameterDict({
185
+ _key(t): nn.Parameter(torch.zeros(n_special, text_hidden))
186
+ for t in config.task_names
187
+ })
188
+
189
+ self._active_task_key = _key(config.task_names[0])
190
+ self._special_token_ids = config.special_token_ids
191
+ self.post_init()
192
+
193
+ @property
194
+ def modality(self) -> str:
195
+ return self._modality
196
+
197
+ def set_task(self, task):
198
+ k = _key(task)
199
+ self._active_task_key = k
200
+ with torch.no_grad():
201
+ w = self.language_model.embed_tokens.weight.data
202
+ te = self.task_token_embeddings[k]
203
+ for i, tid in enumerate(self._special_token_ids):
204
+ w[tid] = te[i]
205
+
206
+ def get_input_embeddings(self):
207
+ return self.language_model.embed_tokens
208
+
209
+ def set_input_embeddings(self, value):
210
+ self.language_model.embed_tokens = value
211
+
212
+ def get_output_embeddings(self):
213
+ return None
214
+
215
+ def get_image_features(self, pixel_values, image_grid_thw, num_image_tokens=None):
216
+ if self._modality in ("audio", "text"):
217
+ raise ValueError(
218
+ f"Vision inputs are not available in {self._modality}-only mode. "
219
+ "Load with modality='omni' or modality='vision'."
220
+ )
221
+
222
+ out = self.vision_tower(hidden_states=pixel_values, grid_thw=image_grid_thw)
223
+ raw = out[0] if isinstance(out, tuple) else getattr(out, "last_hidden_state", out[0])
224
+ merged = self.mergers[self._active_task_key](raw)
225
+
226
+ merge = self._spatial_merge_size
227
+ sizes = []
228
+ for i in range(image_grid_thw.shape[0]):
229
+ t, h, w = image_grid_thw[i].tolist()
230
+ sizes.append(int(t) * (int(h) // merge) * (int(w) // merge))
231
+
232
+ # Default: return the un-padded per-image feature slices. Their
233
+ # concatenation has exactly sum(sizes) rows == number of <image>
234
+ # placeholder tokens in input_ids, which is what masked_scatter
235
+ # consumes. Padding is only meaningful when callers want a square
236
+ # [N, max_tok, dim] block (e.g. multi-sample batched forward where
237
+ # each row owns its own image), and that path passes
238
+ # num_image_tokens explicitly to opt in.
239
+ dim = merged.shape[-1]
240
+ features, offset = [], 0
241
+ if num_image_tokens is not None:
242
+ max_tok = num_image_tokens
243
+ for n in sizes:
244
+ feat = merged[offset:offset + n]
245
+ if n < max_tok:
246
+ feat = torch.cat([feat, feat.new_zeros(max_tok - n, dim)], dim=0)
247
+ features.append(feat)
248
+ offset += n
249
+ else:
250
+ for n in sizes:
251
+ features.append(merged[offset:offset + n])
252
+ offset += n
253
+ return features
254
+
255
+ def get_audio_features(self, input_features, feature_attention_mask=None):
256
+ if self._modality in ("vision", "text"):
257
+ raise ValueError(
258
+ f"Audio inputs are not available in {self._modality}-only mode. "
259
+ "Load with modality='omni' or modality='audio'."
260
+ )
261
+
262
+ batch_size = input_features.shape[0]
263
+ if batch_size > 1:
264
+ # Serialize per-sample so the packed-frames GEMM shape stays invariant
265
+ # across batch sizes. Makes batched audio bit-exact to B=1 in bf16,
266
+ # and is substantially faster for B>=16 because B=1 hits a
267
+ # well-optimized kernel while the packed-B=N path thrashes on a
268
+ # (total_frames)^2 sdpa matrix.
269
+ outs = [
270
+ self.get_audio_features(
271
+ input_features[i : i + 1],
272
+ feature_attention_mask[i : i + 1] if feature_attention_mask is not None else None,
273
+ )
274
+ for i in range(batch_size)
275
+ ]
276
+ return torch.cat(outs, dim=0)
277
+ if feature_attention_mask is not None:
278
+ feature_lens = feature_attention_mask.sum(-1).long()
279
+ packed = input_features.permute(0, 2, 1)[feature_attention_mask.bool()].permute(1, 0)
280
+ else:
281
+ feature_lens = torch.full(
282
+ (batch_size,), input_features.shape[2],
283
+ device=input_features.device, dtype=torch.long,
284
+ )
285
+ packed = input_features.transpose(1, 2).reshape(-1, input_features.shape[1]).T
286
+ aftercnn_lens, _ = self.audio_tower._get_feat_extract_output_lengths(feature_lens)
287
+ audio_output = self.audio_tower(
288
+ packed, feature_lens=feature_lens, aftercnn_lens=aftercnn_lens,
289
+ )
290
+ return self.audio_projectors[self._active_task_key](audio_output.last_hidden_state)
291
+
292
+ def forward(
293
+ self,
294
+ input_ids=None,
295
+ pixel_values=None,
296
+ attention_mask=None,
297
+ position_ids=None,
298
+ past_key_values=None,
299
+ inputs_embeds=None,
300
+ input_features=None,
301
+ feature_attention_mask=None,
302
+ cache_position=None,
303
+ output_hidden_states=None,
304
+ **kwargs,
305
+ ):
306
+ image_grid_thw = kwargs.pop("image_grid_thw", None)
307
+ num_image_tokens = kwargs.pop("num_image_tokens", None)
308
+ pixel_values_videos = kwargs.pop("pixel_values_videos", None)
309
+ video_grid_thw = kwargs.pop("video_grid_thw", None)
310
+ num_video_tokens = kwargs.pop("num_video_tokens", None)
311
+ kwargs.pop("spatial_shapes", None)
312
+ kwargs.pop("pixel_attention_mask", None)
313
+
314
+ if pixel_values is not None and self._modality in ("audio", "text"):
315
+ raise ValueError(
316
+ f"Vision inputs are not available in {self._modality}-only mode. "
317
+ "Load with modality='omni' or modality='vision'."
318
+ )
319
+ if input_features is not None and self._modality in ("vision", "text"):
320
+ raise ValueError(
321
+ f"Audio inputs are not available in {self._modality}-only mode. "
322
+ "Load with modality='omni' or modality='audio'."
323
+ )
324
+
325
+ if (input_ids is None) ^ (inputs_embeds is not None):
326
+ raise ValueError("You must specify exactly one of input_ids or inputs_embeds")
327
+
328
+ if inputs_embeds is None:
329
+ inputs_embeds = self.get_input_embeddings()(input_ids)
330
+
331
+ # Image and video both use config.image_token_index (the processor
332
+ # remaps <|video_pad|> to <image>). When a single forward pass mixes
333
+ # both modalities, the mask matches both sets of placeholders, so we
334
+ # build one combined source with image features first then video
335
+ # features, matching the order of placeholders in input_ids.
336
+ all_feats = []
337
+ if pixel_values is not None and image_grid_thw is not None:
338
+ all_feats.extend(self.get_image_features(pixel_values, image_grid_thw, num_image_tokens))
339
+ if pixel_values_videos is not None and video_grid_thw is not None:
340
+ all_feats.extend(self.get_image_features(pixel_values_videos, video_grid_thw, num_video_tokens))
341
+ if all_feats:
342
+ feats = torch.cat(all_feats, dim=0).to(inputs_embeds.device, inputs_embeds.dtype)
343
+ mask = (input_ids == self.config.image_token_index).unsqueeze(-1).expand_as(inputs_embeds)
344
+ inputs_embeds = inputs_embeds.masked_scatter(mask, feats)
345
+
346
+ if input_features is not None:
347
+ aud = self.get_audio_features(input_features, feature_attention_mask)
348
+ aud_flat = aud.reshape(-1, aud.shape[-1]).to(inputs_embeds.device, inputs_embeds.dtype)
349
+ mask = (input_ids == self.config.audio_token_id).unsqueeze(-1).expand_as(inputs_embeds)
350
+ inputs_embeds = inputs_embeds.masked_scatter(mask, aud_flat)
351
+
352
+ if attention_mask is not None and attention_mask.dim() == 2:
353
+ dtype = inputs_embeds.dtype
354
+ seq_len = inputs_embeds.shape[1]
355
+ bidi = attention_mask[:, None, None, :].to(dtype=dtype)
356
+ bidi = (1.0 - bidi) * torch.finfo(dtype).min
357
+ attention_mask = bidi.expand(-1, -1, seq_len, -1)
358
+
359
+ out = self.language_model(
360
+ attention_mask=attention_mask,
361
+ position_ids=position_ids,
362
+ past_key_values=past_key_values,
363
+ inputs_embeds=inputs_embeds,
364
+ cache_position=cache_position,
365
+ output_hidden_states=output_hidden_states,
366
+ )
367
+
368
+ return BaseModelOutputWithPast(
369
+ last_hidden_state=self.lm_head(out[0]),
370
+ past_key_values=out.past_key_values,
371
+ hidden_states=out.hidden_states,
372
+ attentions=out.attentions,
373
+ )
374
+
375
+
376
+ class JinaEmbeddingsV5OmniModel(PeftMixedModel):
377
+ config_class = JinaEmbeddingsV5OmniConfig
378
+
379
+ @classmethod
380
+ def register_for_auto_class(cls, auto_class="AutoModel"):
381
+ return PreTrainedModel.register_for_auto_class.__func__(cls, auto_class)
382
+
383
+ @classmethod
384
+ def from_pretrained(cls, pretrained_model_name_or_path, *args, **kwargs):
385
+ modality = kwargs.pop("modality", None)
386
+ task_kwarg = kwargs.pop("task", None)
387
+ config = kwargs.pop("config", None)
388
+ if config is None:
389
+ config = JinaEmbeddingsV5OmniConfig.from_pretrained(pretrained_model_name_or_path)
390
+ if modality is not None:
391
+ config.modality = modality
392
+ elif not hasattr(config, "modality") or config.modality is None:
393
+ config.modality = "omni"
394
+
395
+ default_dtype = getattr(config, "torch_dtype", None) or torch.float32
396
+ base_model = JinaEmbeddingsV5OmniBase.from_pretrained(
397
+ pretrained_model_name_or_path,
398
+ config=config,
399
+ torch_dtype=kwargs.pop("torch_dtype", kwargs.pop("dtype", default_dtype)),
400
+ )
401
+
402
+ if os.path.isdir(pretrained_model_name_or_path):
403
+ adapters_dir = os.path.join(pretrained_model_name_or_path, "adapters")
404
+ else:
405
+ cache = snapshot_download(
406
+ repo_id=pretrained_model_name_or_path,
407
+ allow_patterns=["adapters/*"],
408
+ )
409
+ adapters_dir = os.path.join(cache, "adapters")
410
+
411
+ adapter_paths = {
412
+ name: os.path.join(adapters_dir, name) for name in config.task_names
413
+ }
414
+
415
+ peft_config = PeftConfig.from_pretrained(adapter_paths["retrieval"], **kwargs)
416
+ model = cls(base_model, peft_config, adapter_name="retrieval")
417
+ model._pretrained_path = pretrained_model_name_or_path
418
+ for name in config.task_names:
419
+ model.load_adapter(adapter_paths[name], adapter_name=name, **kwargs)
420
+
421
+ model.tokenizer = AutoTokenizer.from_pretrained(
422
+ pretrained_model_name_or_path, trust_remote_code=True,
423
+ )
424
+ # Task precedence: kwarg > config.task (hf_overrides path) > env var > default.
425
+ task = task_kwarg
426
+ if task is None:
427
+ task = getattr(config, "task", None)
428
+ if task is None:
429
+ task = os.environ.get("JINA_V5_TASK")
430
+ if task is None:
431
+ task = config.task_names[0]
432
+ if task not in config.task_names:
433
+ raise ValueError(
434
+ f"task must be one of {config.task_names}, got '{task}'"
435
+ )
436
+ model.set_adapter(task)
437
+ return model
438
+
439
+ @property
440
+ def modality(self) -> str:
441
+ return self.base_model.model.modality
442
+
443
+ def set_adapter(self, adapters):
444
+ super().set_adapter(adapters)
445
+ task = adapters[0] if isinstance(adapters, list) else adapters
446
+ self.base_model.model.set_task(task)
447
+
448
+ def encode(
449
+ self,
450
+ texts: List[str],
451
+ task: str,
452
+ prompt_name: Optional[str] = "document",
453
+ truncate_dim: Optional[int] = None,
454
+ max_length: Optional[int] = None,
455
+ ) -> torch.Tensor:
456
+ cfg = self.base_model.model.config
457
+ if task not in cfg.task_names:
458
+ raise ValueError(f"Unknown task: {task}")
459
+ if prompt_name is None:
460
+ prompt_name = "document"
461
+ if prompt_name not in {"query", "document"}:
462
+ raise ValueError(f"Unknown prompt_name: {prompt_name}")
463
+
464
+ prefix = "Query: " if prompt_name == "query" else "Document: "
465
+ inputs = [f"{prefix}{t}" for t in texts]
466
+
467
+ max_length = max_length or cfg.text_config.max_position_embeddings
468
+ batch = self.tokenizer(
469
+ inputs, return_tensors="pt", padding=True, truncation=True, max_length=max_length,
470
+ )
471
+ device = next(self.parameters()).device
472
+ batch = {k: v.to(device) for k, v in batch.items()}
473
+ self.set_adapter([task])
474
+ self.eval()
475
+ with torch.no_grad():
476
+ hidden = self(**batch).last_hidden_state
477
+ mask = batch.get("attention_mask")
478
+ if mask is None:
479
+ pooled = hidden[:, -1]
480
+ else:
481
+ seq_lens = mask.sum(dim=1) - 1
482
+ pooled = hidden[torch.arange(hidden.shape[0], device=hidden.device), seq_lens]
483
+ if truncate_dim is not None:
484
+ pooled = pooled[:, :truncate_dim]
485
+ return F.normalize(pooled, p=2, dim=-1)
486
+
487
+ def embed(self, truncate_dim: Optional[int] = None, **inputs):
488
+ """Encode processor outputs into L2-normalized last-token embeddings.
489
+
490
+ Matryoshka: pass `truncate_dim=N` to get an N-dim unit-norm vector
491
+ (truncation is applied before L2-normalization).
492
+ """
493
+ attention_mask = inputs.get("attention_mask", None)
494
+ self.eval()
495
+ with torch.no_grad():
496
+ out = self(**inputs)
497
+ hidden = out.last_hidden_state
498
+ if attention_mask is not None and attention_mask.dim() == 2:
499
+ idx = attention_mask.sum(dim=1) - 1
500
+ else:
501
+ idx = torch.full(
502
+ (hidden.shape[0],), hidden.shape[1] - 1,
503
+ device=hidden.device, dtype=torch.long,
504
+ )
505
+ pooled = hidden[torch.arange(hidden.shape[0], device=hidden.device), idx]
506
+ if truncate_dim is not None:
507
+ pooled = pooled[:, :truncate_dim]
508
+ return torch.nn.functional.normalize(pooled, dim=-1)
509
+
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # vLLM registration (side-effect on module import).
514
+ #
515
+ # Triggered via config.json "auto_map.AutoConfig" -> this module.
516
+ # HF / sentence-transformers path unaffected: any failure is silently swallowed
517
+ # so that pure transformers users never see a vLLM error.
518
+ # ---------------------------------------------------------------------------
519
+
520
+ def _register_vllm() -> None:
521
+ # All vLLM references are resolved via importlib so transformers'
522
+ # static check_imports does NOT flag vllm as a required dependency.
523
+ # Pure-HF / sentence-transformers usage is unaffected.
524
+ #
525
+ # When loaded via transformers' `trust_remote_code=True`, only the
526
+ # modeling_*.py referenced in auto_map is fetched into the
527
+ # transformers_modules cache — sibling vLLM adapter files are NOT.
528
+ # We pull them from HF Hub before registering; otherwise vLLM falls
529
+ # back to its transformers backend (wrong attention semantics) and
530
+ # multi-request batches collapse.
531
+ import importlib.util as _iu
532
+ if _iu.find_spec("vllm") is None:
533
+ return
534
+ try:
535
+ import os
536
+ import sys
537
+ import importlib
538
+ import inspect
539
+ import shutil
540
+
541
+ pkg = __package__ or ""
542
+ current_dir = os.path.dirname(os.path.abspath(__file__))
543
+ siblings = ("vllm_llava_eurobert_audio", "vllm_jina_v5_omni")
544
+
545
+ for sibling_name in siblings:
546
+ sibling_path = os.path.join(current_dir, sibling_name + ".py")
547
+ if os.path.exists(sibling_path):
548
+ continue
549
+ parts = pkg.split(".")
550
+ if len(parts) < 4 or parts[0] != "transformers_modules":
551
+ continue
552
+ from huggingface_hub import hf_hub_download
553
+ repo_name = parts[2].replace("_hyphen_", "-").replace("_dot_", ".")
554
+ repo_id = f"{parts[1]}/{repo_name}"
555
+ downloaded = hf_hub_download(
556
+ repo_id=repo_id,
557
+ filename=sibling_name + ".py",
558
+ revision=parts[3],
559
+ )
560
+ shutil.copy(downloaded, sibling_path)
561
+
562
+ os.environ.setdefault("VLLM_ENABLE_V1_MULTIPROCESSING", "0")
563
+
564
+ _kvc = importlib.import_module("vllm.v1.core.kv_cache_coordinator")
565
+ _orig = _kvc.get_kv_cache_coordinator
566
+ _NoPrefix = _kvc.KVCacheCoordinatorNoPrefixCache
567
+
568
+ _orig_sig = inspect.signature(_orig)
569
+ _noprefix_sig = inspect.signature(_NoPrefix)
570
+
571
+ def _patched(kv_cache_config, max_model_len, *args, **kwargs):
572
+ if len(kv_cache_config.kv_cache_groups) == 0:
573
+ bound = _orig_sig.bind(kv_cache_config, max_model_len, *args, **kwargs)
574
+ return _NoPrefix(**{
575
+ name: bound.arguments[name]
576
+ for name in _noprefix_sig.parameters
577
+ if name in bound.arguments
578
+ })
579
+ return _orig(kv_cache_config, max_model_len, *args, **kwargs)
580
+
581
+ _kvc.get_kv_cache_coordinator = _patched
582
+
583
+ # Make sibling-dir importable from a fresh subprocess too — vLLM's
584
+ # inspect_model_cls runs in a child Python process that doesn't
585
+ # inherit our sys.modules. Without this on PYTHONPATH the
586
+ # string-spec model registration below can't be resolved.
587
+ if current_dir not in sys.path:
588
+ sys.path.insert(0, current_dir)
589
+ existing = os.environ.get("PYTHONPATH", "")
590
+ if current_dir not in existing.split(os.pathsep):
591
+ os.environ["PYTHONPATH"] = (
592
+ current_dir if not existing else current_dir + os.pathsep + existing
593
+ )
594
+
595
+ if pkg:
596
+ _lla = importlib.import_module(".vllm_llava_eurobert_audio", package=pkg)
597
+ _omni = importlib.import_module(".vllm_jina_v5_omni", package=pkg)
598
+ else:
599
+ _lla = importlib.import_module("vllm_llava_eurobert_audio")
600
+ _omni = importlib.import_module("vllm_jina_v5_omni")
601
+ _ = _lla.LlavaEuroBertAudioForVLLMEmbedding # keep reference
602
+
603
+ ModelRegistry = importlib.import_module(
604
+ "vllm.model_executor.models"
605
+ ).ModelRegistry
606
+ # String spec ("module:Class") — survives vLLM's cloudpickle-into-
607
+ # subprocess flow because the child re-imports by name. Passing the
608
+ # class object directly registers __module__ as the qualified
609
+ # transformers_modules.jinaai.<...> path, which the subprocess
610
+ # can't resolve without HF's dynamic-module setup.
611
+ ModelRegistry.register_model(
612
+ "JinaEmbeddingsV5OmniModel",
613
+ "vllm_jina_v5_omni:JinaV5OmniForVLLMEmbedding",
614
+ )
615
+ except Exception as e:
616
+ import warnings
617
+ warnings.warn(
618
+ f"jina-embeddings-v5-omni base: vLLM registration failed "
619
+ f"({type(e).__name__}: {e}); embeddings will fall back to "
620
+ f"vLLM's generic transformers backend (wrong tensor layout).",
621
+ stacklevel=2,
622
+ )
623
+
624
+
625
+ _register_vllm()
jina_v5_omni_tokenizer/modeling_llava_eurobert_audio.py ADDED
@@ -0,0 +1,409 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ LlavaEuroBertAudioForEmbedding: Qwen3VL vision + Qwen2.5-Omni audio + EuroBERT text.
3
+
4
+ Architecture:
5
+ - Vision: Qwen3VLVisionModel (with RoPE, 3D Conv3d patch embed, all layers)
6
+ - Merger: PretrainedMerger (top-level, NOT inside vision_tower)
7
+ - Audio: Qwen2_5OmniAudioEncoder (Qwen2.5-Omni) + Linear projector
8
+ - Text: LlamaModel (EuroBERT, bidirectional)
9
+ - LM head: Identity (embedding model, no vocab projection)
10
+
11
+ Modality loading:
12
+ model = AutoModel.from_pretrained(path, trust_remote_code=True, modality="omni") # all components (default)
13
+ model = AutoModel.from_pretrained(path, trust_remote_code=True, modality="vision") # no audio tower/projector
14
+ model = AutoModel.from_pretrained(path, trust_remote_code=True, modality="audio") # no vision tower/merger
15
+ """
16
+
17
+ from typing import List, Optional, Union
18
+
19
+ import torch
20
+ import torch.nn as nn
21
+ from transformers import LlamaConfig, PreTrainedModel, PretrainedConfig
22
+ from transformers.modeling_outputs import BaseModelOutputWithPast
23
+ from transformers.models.llama.modeling_llama import LlamaModel
24
+ from transformers.models.qwen3_vl.configuration_qwen3_vl import Qwen3VLVisionConfig
25
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLVisionModel
26
+ from transformers.models.qwen2_5_omni.configuration_qwen2_5_omni import Qwen2_5OmniAudioEncoderConfig
27
+ from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
28
+
29
+
30
+ _VALID_MODALITIES = ("omni", "vision", "audio", "text")
31
+
32
+
33
+ class PretrainedMerger(nn.Module):
34
+ def __init__(self, hidden_size, out_hidden_size, spatial_merge_size=2):
35
+ super().__init__()
36
+ self.hidden_size = hidden_size * (spatial_merge_size**2)
37
+ self.norm = nn.LayerNorm(hidden_size, eps=1e-6)
38
+ self.linear_fc1 = nn.Linear(self.hidden_size, self.hidden_size)
39
+ self.act = nn.GELU()
40
+ self.linear_fc2 = nn.Linear(self.hidden_size, out_hidden_size)
41
+
42
+ def forward(self, x):
43
+ x = self.norm(x)
44
+ x = x.view(-1, self.hidden_size)
45
+ x = self.linear_fc2(self.act(self.linear_fc1(x)))
46
+ return x
47
+
48
+
49
+ class LlavaEuroBertAudioConfig(PretrainedConfig):
50
+ model_type = "llava_eurobert_audio"
51
+
52
+ def __init__(
53
+ self,
54
+ vision_config=None,
55
+ text_config=None,
56
+ audio_config=None,
57
+ image_token_index=None,
58
+ audio_token_id=None,
59
+ audio_start_token_id=None,
60
+ audio_end_token_id=None,
61
+ projector_hidden_act="gelu",
62
+ tie_word_embeddings=False,
63
+ modality="omni",
64
+ **kwargs,
65
+ ):
66
+ if isinstance(vision_config, dict):
67
+ vision_config = PretrainedConfig(**vision_config)
68
+ self.vision_config = vision_config or PretrainedConfig()
69
+ if isinstance(text_config, dict):
70
+ text_config = PretrainedConfig(**text_config)
71
+ self.text_config = text_config or PretrainedConfig()
72
+ if isinstance(audio_config, dict):
73
+ audio_config = PretrainedConfig(**audio_config)
74
+ self.audio_config = audio_config or PretrainedConfig()
75
+ self.image_token_index = image_token_index
76
+ self.audio_token_id = audio_token_id
77
+ self.audio_start_token_id = audio_start_token_id
78
+ self.audio_end_token_id = audio_end_token_id
79
+ self.projector_hidden_act = projector_hidden_act
80
+ if modality not in _VALID_MODALITIES:
81
+ raise ValueError(f"modality must be one of {_VALID_MODALITIES}, got '{modality}'")
82
+ self.modality = modality
83
+ super().__init__(tie_word_embeddings=tie_word_embeddings, **kwargs)
84
+
85
+ def get_text_config(self, **kwargs):
86
+ return self.text_config
87
+
88
+
89
+ class LlavaEuroBertAudioForEmbedding(PreTrainedModel):
90
+ config_class = LlavaEuroBertAudioConfig
91
+ supports_gradient_checkpointing = True
92
+ _supports_sdpa = True
93
+ _supports_flash_attn_2 = True
94
+ _supports_flash_attn = True
95
+ _supports_attention_backend = True
96
+ _tied_weights_keys = []
97
+ _keys_to_ignore_on_load_missing = ["lm_head.weight"]
98
+ _keys_to_ignore_on_load_unexpected = []
99
+
100
+ def __init__(self, config: LlavaEuroBertAudioConfig):
101
+ super().__init__(config)
102
+
103
+ modality = getattr(config, "modality", "omni")
104
+ if modality not in _VALID_MODALITIES:
105
+ raise ValueError(f"modality must be one of {_VALID_MODALITIES}, got '{modality}'")
106
+ self._modality = modality
107
+ # propagate attn_implementation into the inner towers. The EuroBERT
108
+ # text encoder is bidirectional/non-causal; flash-attn varlen does not
109
+ # support it, so the text tower is kept on sdpa.
110
+ attn_impl = self.config._attn_implementation
111
+ text_attn_impl = "sdpa" if (attn_impl and "flash" in str(attn_impl)) else attn_impl
112
+
113
+ vision_cfg = config.vision_config
114
+ if not isinstance(vision_cfg, Qwen3VLVisionConfig):
115
+ if hasattr(vision_cfg, "to_dict"):
116
+ d = vision_cfg.to_dict()
117
+ else:
118
+ d = dict(vision_cfg)
119
+ d.pop("model_type", None)
120
+ d.pop("transformers_version", None)
121
+ vision_cfg = Qwen3VLVisionConfig(**d)
122
+
123
+ vision_cfg.deepstack_visual_indexes = []
124
+ vision_cfg._attn_implementation = attn_impl
125
+ spatial_merge_size = getattr(vision_cfg, "spatial_merge_size", 2)
126
+
127
+ text_cfg = config.text_config
128
+ if not isinstance(text_cfg, LlamaConfig):
129
+ txt_dict = text_cfg.to_dict() if hasattr(text_cfg, 'to_dict') else dict(text_cfg)
130
+ _saved_attn_impl = getattr(text_cfg, "_attn_implementation", None)
131
+ text_cfg = LlamaConfig(**txt_dict)
132
+ if _saved_attn_impl is not None:
133
+ text_cfg._attn_implementation = _saved_attn_impl
134
+ text_hidden = text_cfg.hidden_size
135
+
136
+ self._spatial_merge_size = spatial_merge_size
137
+ self._vision_hidden_size = getattr(vision_cfg, "hidden_size", 768)
138
+
139
+ if modality not in ("audio", "text"):
140
+ self.vision_tower = Qwen3VLVisionModel(vision_cfg)
141
+ self.vision_tower.merger = nn.Identity()
142
+ self.vision_tower.deepstack_merger_list = nn.ModuleList()
143
+ self.vision_tower.deepstack_visual_indexes = []
144
+ self.merger = PretrainedMerger(
145
+ vision_cfg.hidden_size, text_hidden, spatial_merge_size
146
+ )
147
+
148
+ self.multi_modal_projector = nn.Identity()
149
+ text_cfg._attn_implementation = text_attn_impl
150
+ self.language_model = LlamaModel(text_cfg)
151
+ self.lm_head = nn.Identity()
152
+
153
+ for layer in self.language_model.layers:
154
+ layer.self_attn.is_causal = False
155
+
156
+ if modality not in ("vision", "text"):
157
+ aud_cfg = config.audio_config
158
+ aud_dict = aud_cfg.to_dict() if hasattr(aud_cfg, 'to_dict') else aud_cfg
159
+ audio_encoder_config = Qwen2_5OmniAudioEncoderConfig(**aud_dict)
160
+ audio_encoder_config._attn_implementation = attn_impl
161
+ self.audio_tower = Qwen2_5OmniAudioEncoder(audio_encoder_config)
162
+ output_dim = aud_dict.get('output_dim', 3584)
163
+ self.audio_projector = nn.Linear(output_dim, text_hidden)
164
+
165
+ ignore = []
166
+ if modality in ("audio", "text"):
167
+ ignore.extend([r"^vision_tower\.", r"^merger\."])
168
+ if modality in ("vision", "text"):
169
+ ignore.extend([r"^audio_tower\.", r"^audio_projector\."])
170
+ if ignore:
171
+ self._keys_to_ignore_on_load_unexpected = ignore
172
+
173
+ self.post_init()
174
+
175
+ @property
176
+ def modality(self) -> str:
177
+ return self._modality
178
+
179
+ def get_input_embeddings(self):
180
+ return self.language_model.embed_tokens
181
+
182
+ def set_input_embeddings(self, value):
183
+ self.language_model.embed_tokens = value
184
+
185
+ def get_output_embeddings(self):
186
+ return None
187
+
188
+ def get_image_features(
189
+ self,
190
+ pixel_values: torch.FloatTensor,
191
+ image_grid_thw: torch.LongTensor,
192
+ num_image_tokens: Optional[int] = None,
193
+ ) -> List[torch.Tensor]:
194
+ if self._modality in ("audio", "text"):
195
+ raise ValueError(
196
+ f"Vision inputs are not available in {self._modality}-only mode. "
197
+ "Load with modality='omni' or modality='vision'."
198
+ )
199
+
200
+ vision_output = self.vision_tower(
201
+ hidden_states=pixel_values, grid_thw=image_grid_thw
202
+ )
203
+ if isinstance(vision_output, tuple):
204
+ raw_hidden = vision_output[0]
205
+ elif hasattr(vision_output, "pooler_output") and vision_output.pooler_output is not None:
206
+ raw_hidden = vision_output.pooler_output
207
+ else:
208
+ raw_hidden = vision_output[0]
209
+
210
+ image_features = self.merger(raw_hidden)
211
+
212
+ merge_sq = self._spatial_merge_size ** 2
213
+ split_sizes = (image_grid_thw.prod(-1) // merge_sq).tolist()
214
+ return list(torch.split(image_features, split_sizes))
215
+
216
+ def get_audio_features(
217
+ self,
218
+ input_features: torch.FloatTensor,
219
+ feature_attention_mask: Optional[torch.LongTensor] = None,
220
+ ) -> torch.Tensor:
221
+ if self._modality in ("vision", "text"):
222
+ raise ValueError(
223
+ f"Audio inputs are not available in {self._modality}-only mode. "
224
+ "Load with modality='omni' or modality='audio'."
225
+ )
226
+
227
+ batch_size = input_features.shape[0]
228
+ if batch_size > 1:
229
+ # Serialize per-sample so the packed-frames GEMM shape stays invariant
230
+ # across batch sizes. Makes batched audio bit-exact to B=1 in bf16,
231
+ # and is substantially faster for B>=16 because B=1 hits a
232
+ # well-optimized kernel while the packed-B=N path thrashes on a
233
+ # (total_frames)^2 sdpa matrix.
234
+ outs = [
235
+ self.get_audio_features(
236
+ input_features[i : i + 1],
237
+ feature_attention_mask[i : i + 1] if feature_attention_mask is not None else None,
238
+ )
239
+ for i in range(batch_size)
240
+ ]
241
+ return torch.cat(outs, dim=0)
242
+ if feature_attention_mask is not None:
243
+ feature_lens = feature_attention_mask.sum(-1).long()
244
+ packed = input_features.permute(0, 2, 1)[feature_attention_mask.bool()].permute(1, 0)
245
+ else:
246
+ feature_lens = torch.full(
247
+ (batch_size,), input_features.shape[2],
248
+ device=input_features.device, dtype=torch.long,
249
+ )
250
+ packed = input_features.transpose(1, 2).reshape(-1, input_features.shape[1]).T
251
+ aftercnn_lens, _ = self.audio_tower._get_feat_extract_output_lengths(feature_lens)
252
+ audio_output = self.audio_tower(
253
+ packed, feature_lens=feature_lens, aftercnn_lens=aftercnn_lens,
254
+ )
255
+ return self.audio_projector(audio_output.last_hidden_state)
256
+
257
+ def forward(
258
+ self,
259
+ input_ids: Optional[torch.LongTensor] = None,
260
+ pixel_values: Optional[torch.FloatTensor] = None,
261
+ attention_mask: Optional[torch.Tensor] = None,
262
+ position_ids: Optional[torch.LongTensor] = None,
263
+ past_key_values=None,
264
+ inputs_embeds: Optional[torch.FloatTensor] = None,
265
+ input_features: Optional[torch.FloatTensor] = None,
266
+ feature_attention_mask: Optional[torch.LongTensor] = None,
267
+ cache_position: Optional[torch.LongTensor] = None,
268
+ output_hidden_states: Optional[bool] = None,
269
+ **kwargs,
270
+ ):
271
+ image_grid_thw = kwargs.pop("image_grid_thw", None)
272
+ num_image_tokens = kwargs.pop("num_image_tokens", None)
273
+ kwargs.pop("spatial_shapes", None)
274
+ kwargs.pop("pixel_attention_mask", None)
275
+
276
+ if pixel_values is not None and self._modality in ("audio", "text"):
277
+ raise ValueError(
278
+ f"Vision inputs are not available in {self._modality}-only mode. "
279
+ "Load with modality='omni' or modality='vision'."
280
+ )
281
+ if input_features is not None and self._modality in ("vision", "text"):
282
+ raise ValueError(
283
+ f"Audio inputs are not available in {self._modality}-only mode. "
284
+ "Load with modality='omni' or modality='audio'."
285
+ )
286
+
287
+ if (input_ids is None) ^ (inputs_embeds is not None):
288
+ raise ValueError(
289
+ "You must specify exactly one of input_ids or inputs_embeds"
290
+ )
291
+
292
+ if inputs_embeds is None:
293
+ inputs_embeds = self.get_input_embeddings()(input_ids)
294
+
295
+ if pixel_values is not None and image_grid_thw is not None:
296
+ image_features = self.get_image_features(
297
+ pixel_values=pixel_values,
298
+ image_grid_thw=image_grid_thw,
299
+ num_image_tokens=num_image_tokens,
300
+ )
301
+ image_features = torch.cat(image_features, dim=0).to(
302
+ inputs_embeds.device, inputs_embeds.dtype
303
+ )
304
+ special_image_mask = (
305
+ (input_ids == self.config.image_token_index)
306
+ .unsqueeze(-1)
307
+ .expand_as(inputs_embeds)
308
+ )
309
+ inputs_embeds = inputs_embeds.masked_scatter(
310
+ special_image_mask, image_features
311
+ )
312
+
313
+ if input_features is not None:
314
+ audio_embeds = self.get_audio_features(
315
+ input_features, feature_attention_mask
316
+ )
317
+ audio_embeds_flat = audio_embeds.reshape(
318
+ -1, audio_embeds.shape[-1]
319
+ ).to(inputs_embeds.device, inputs_embeds.dtype)
320
+ audio_mask = (
321
+ (input_ids == self.config.audio_token_id)
322
+ .unsqueeze(-1)
323
+ .expand_as(inputs_embeds)
324
+ )
325
+ inputs_embeds = inputs_embeds.masked_scatter(
326
+ audio_mask, audio_embeds_flat
327
+ )
328
+
329
+ if attention_mask is not None and attention_mask.dim() == 2:
330
+ dtype = inputs_embeds.dtype
331
+ seq_len = inputs_embeds.shape[1]
332
+ bidi_mask = attention_mask[:, None, None, :].to(dtype=dtype)
333
+ bidi_mask = (1.0 - bidi_mask) * torch.finfo(dtype).min
334
+ attention_mask = bidi_mask.expand(-1, -1, seq_len, -1)
335
+
336
+ # vLLM's transformers backend passes `return_dict=False` + `attention_instances`.
337
+ # Force dict-style output internally, and forward remaining kwargs so the
338
+ # vllm attention hook receives its `attention_instances` dict.
339
+ kwargs.pop("return_dict", None)
340
+ outputs = self.language_model(
341
+ attention_mask=attention_mask,
342
+ position_ids=position_ids,
343
+ past_key_values=past_key_values,
344
+ inputs_embeds=inputs_embeds,
345
+ cache_position=cache_position,
346
+ output_hidden_states=output_hidden_states,
347
+ return_dict=True,
348
+ **kwargs,
349
+ )
350
+
351
+ hidden_states = outputs[0]
352
+ logits = self.lm_head(hidden_states)
353
+
354
+ return BaseModelOutputWithPast(
355
+ last_hidden_state=logits,
356
+ past_key_values=outputs.past_key_values,
357
+ hidden_states=outputs.hidden_states,
358
+ attentions=outputs.attentions,
359
+ )
360
+
361
+
362
+ def _register_vllm() -> None:
363
+ import importlib.util as _iu
364
+ if _iu.find_spec("vllm") is None:
365
+ return
366
+ try:
367
+ import os, sys, importlib, shutil
368
+ pkg = __package__ or ""
369
+ current_dir = os.path.dirname(os.path.abspath(__file__))
370
+ sibling_name = "vllm_llava_eurobert_audio"
371
+ sibling_path = os.path.join(current_dir, sibling_name + ".py")
372
+ if not os.path.exists(sibling_path):
373
+ parts = pkg.split(".")
374
+ if len(parts) >= 4 and parts[0] == "transformers_modules":
375
+ from huggingface_hub import hf_hub_download
376
+ repo_name = parts[2].replace("_hyphen_", "-").replace("_dot_", ".")
377
+ repo_id = f"{parts[1]}/{repo_name}"
378
+ downloaded = hf_hub_download(
379
+ repo_id=repo_id,
380
+ filename=sibling_name + ".py",
381
+ revision=parts[3],
382
+ )
383
+ shutil.copy(downloaded, sibling_path)
384
+ if current_dir not in sys.path:
385
+ sys.path.insert(0, current_dir)
386
+ existing = os.environ.get("PYTHONPATH", "")
387
+ if current_dir not in existing.split(os.pathsep):
388
+ os.environ["PYTHONPATH"] = (
389
+ current_dir if not existing else current_dir + os.pathsep + existing
390
+ )
391
+ if pkg:
392
+ _lla = importlib.import_module("." + sibling_name, package=pkg)
393
+ else:
394
+ _lla = importlib.import_module(sibling_name)
395
+ from vllm import ModelRegistry
396
+ ModelRegistry.register_model(
397
+ "LlavaEuroBertAudioForEmbedding",
398
+ _lla.LlavaEuroBertAudioForVLLMEmbedding,
399
+ )
400
+ except Exception as e:
401
+ import warnings
402
+ warnings.warn(
403
+ f"jina-embeddings-v5-omni nano: vLLM registration failed "
404
+ f"({type(e).__name__}: {e}); falling back to Transformers backend.",
405
+ stacklevel=2,
406
+ )
407
+
408
+
409
+ _register_vllm()
jina_v5_omni_tokenizer/modules.json ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "idx": 0,
4
+ "name": "transformer",
5
+ "path": "",
6
+ "type": "custom_st.Transformer"
7
+ }
8
+ ]
jina_v5_omni_tokenizer/preprocessor_config.json ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_convert_rgb": true,
3
+ "do_normalize": true,
4
+ "do_rescale": true,
5
+ "do_resize": true,
6
+ "image_mean": [
7
+ 0.5,
8
+ 0.5,
9
+ 0.5
10
+ ],
11
+ "image_processor_type": "Qwen2VLImageProcessor",
12
+ "image_std": [
13
+ 0.5,
14
+ 0.5,
15
+ 0.5
16
+ ],
17
+ "merge_size": 2,
18
+ "patch_size": 16,
19
+ "resample": 3,
20
+ "rescale_factor": 0.00392156862745098,
21
+ "temporal_patch_size": 2,
22
+ "min_pixels": 262144,
23
+ "max_pixels": 1310720
24
+ }
jina_v5_omni_tokenizer/processing_llava_eurobert.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Custom processor for jina-embeddings-v5-omni-nano.
2
+
3
+ Keeps Qwen2VL image/video preprocessing (pixel_values, pixel_values_videos,
4
+ image_grid_thw, video_grid_thw) and folds both media placeholders into nano's
5
+ single `<image>` tokenizer token in the final tokenized output.
6
+
7
+ Mixed image+video inputs use distinct intermediate markers per modality so
8
+ the image- and video-expansion passes don't collide on a shared `<image>`
9
+ token — which is the root cause of the upstream Qwen2VLProcessor crash that
10
+ walks `while self.image_token in text[i]` and IndexErrors into image_grid_thw
11
+ when video placeholders are still in the text.
12
+
13
+ Two prompt conventions are recognised and disambiguated before expansion:
14
+
15
+ 1. Proper Qwen placeholders — `<|image_pad|>` / `<|video_pad|>` (optionally
16
+ wrapped in `<|vision_start|>`/`<|vision_end|>`). The pre-replace pass
17
+ maps each to its own modality marker.
18
+
19
+ 2. Bare `<image>` literals (the legacy convention emitted by `custom_st.py`
20
+ when chat templates collapse `image_token` and `video_token` to the
21
+ same string). Remaining bare `<image>` literals after pass 1 are
22
+ assigned to modality markers in order: as many as are still required
23
+ by `images` first, then `videos`. Anything beyond the matched count
24
+ is left as a literal `<image>` token (preserving the old single-modality
25
+ fallback).
26
+
27
+ After per-modality expansion both markers collapse to the real `<image>`
28
+ token before the tokenizer runs, so input_ids carry exactly the right
29
+ number of `<image>` ids in the right positions for masked_scatter to fill
30
+ with concatenated image+video features.
31
+ """
32
+
33
+ import numpy as np
34
+
35
+ from transformers.feature_extraction_utils import BatchFeature
36
+ from transformers.models.qwen2_vl.processing_qwen2_vl import (
37
+ Qwen2VLProcessor,
38
+ Qwen2VLProcessorKwargs,
39
+ )
40
+
41
+
42
+ class LlavaEuroBertProcessor(Qwen2VLProcessor):
43
+
44
+ _IMG_MARKER = "<__JINA_IMG_PAD__>"
45
+ _VID_MARKER = "<__JINA_VID_PAD__>"
46
+
47
+ def __init__(
48
+ self,
49
+ image_processor=None,
50
+ tokenizer=None,
51
+ video_processor=None,
52
+ chat_template=None,
53
+ **kwargs,
54
+ ):
55
+ super().__init__(
56
+ image_processor=image_processor,
57
+ tokenizer=tokenizer,
58
+ video_processor=video_processor,
59
+ chat_template=chat_template,
60
+ **kwargs,
61
+ )
62
+ self.image_token = "<image>"
63
+ self.image_token_id = tokenizer.convert_tokens_to_ids(
64
+ self.image_token
65
+ )
66
+ self.video_token = "<image>"
67
+ self.video_token_id = self.image_token_id
68
+
69
+ def __call__(
70
+ self, images=None, text=None, videos=None, **kwargs
71
+ ):
72
+ output_kwargs = self._merge_kwargs(
73
+ Qwen2VLProcessorKwargs,
74
+ tokenizer_init_kwargs=self.tokenizer.init_kwargs,
75
+ **kwargs,
76
+ )
77
+
78
+ image_inputs: dict = {}
79
+ videos_inputs: dict = {}
80
+ image_grid_thw = None
81
+ video_grid_thw = None
82
+ if images is not None:
83
+ image_inputs = self.image_processor(
84
+ images=images, **output_kwargs["images_kwargs"]
85
+ )
86
+ image_grid_thw = image_inputs["image_grid_thw"]
87
+ if videos is not None:
88
+ videos_inputs = self.video_processor(
89
+ videos=videos, **output_kwargs["videos_kwargs"]
90
+ )
91
+ video_grid_thw = videos_inputs["video_grid_thw"]
92
+
93
+ if text is None:
94
+ return BatchFeature(
95
+ data={**image_inputs, **videos_inputs},
96
+ tensor_type=output_kwargs["text_kwargs"].get("return_tensors"),
97
+ )
98
+ if isinstance(text, str):
99
+ text = [text]
100
+ text = list(text)
101
+
102
+ for i in range(len(text)):
103
+ t = text[i]
104
+ t = t.replace(
105
+ "<|vision_start|><|image_pad|><|vision_end|>",
106
+ self._IMG_MARKER,
107
+ )
108
+ t = t.replace(
109
+ "<|vision_start|><|video_pad|><|vision_end|>",
110
+ self._VID_MARKER,
111
+ )
112
+ t = t.replace("<|image_pad|>", self._IMG_MARKER)
113
+ t = t.replace("<|video_pad|>", self._VID_MARKER)
114
+ t = t.replace("<|vision_start|>", "")
115
+ t = t.replace("<|vision_end|>", "")
116
+ text[i] = t
117
+
118
+ # Count from the preprocessing output (one grid row per image/video),
119
+ # not from len(images): the latter crashes on a single PIL.Image and
120
+ # mis-counts batched ndarray inputs. grid length is authoritative and
121
+ # matches the number of markers consumed during expansion below.
122
+ n_images = len(image_grid_thw) if image_grid_thw is not None else 0
123
+ n_videos = len(video_grid_thw) if video_grid_thw is not None else 0
124
+ img_markers_in_text = sum(t.count(self._IMG_MARKER) for t in text)
125
+ vid_markers_in_text = sum(t.count(self._VID_MARKER) for t in text)
126
+ images_to_match = max(0, n_images - img_markers_in_text)
127
+ videos_to_match = max(0, n_videos - vid_markers_in_text)
128
+
129
+ if images_to_match or videos_to_match:
130
+ for i in range(len(text)):
131
+ if self.image_token not in text[i]:
132
+ continue
133
+ parts = text[i].split(self.image_token)
134
+ rebuilt = [parts[0]]
135
+ for p in parts[1:]:
136
+ if images_to_match > 0:
137
+ rebuilt.append(self._IMG_MARKER)
138
+ images_to_match -= 1
139
+ elif videos_to_match > 0:
140
+ rebuilt.append(self._VID_MARKER)
141
+ videos_to_match -= 1
142
+ else:
143
+ rebuilt.append(self.image_token)
144
+ rebuilt.append(p)
145
+ text[i] = "".join(rebuilt)
146
+
147
+ if images is not None and image_grid_thw is not None:
148
+ merge_length = self.image_processor.merge_size ** 2
149
+ index = 0
150
+ for i in range(len(text)):
151
+ while self._IMG_MARKER in text[i]:
152
+ n = int(image_grid_thw[index].prod()) // merge_length
153
+ text[i] = text[i].replace(
154
+ self._IMG_MARKER, self.image_token * n, 1
155
+ )
156
+ index += 1
157
+
158
+ if videos is not None and video_grid_thw is not None:
159
+ merge_length = self.video_processor.merge_size ** 2
160
+ index = 0
161
+ for i in range(len(text)):
162
+ while self._VID_MARKER in text[i]:
163
+ n = int(video_grid_thw[index].prod()) // merge_length
164
+ text[i] = text[i].replace(
165
+ self._VID_MARKER, self.video_token * n, 1
166
+ )
167
+ index += 1
168
+
169
+ return_tensors = output_kwargs["text_kwargs"].pop("return_tensors", None)
170
+ return_mm_token_type_ids = output_kwargs["text_kwargs"].pop(
171
+ "return_mm_token_type_ids", False
172
+ )
173
+ text_inputs = self.tokenizer(
174
+ text, **output_kwargs["text_kwargs"], return_tensors=None
175
+ )
176
+ self._check_special_mm_tokens(
177
+ text, text_inputs, modalities=["image", "video"]
178
+ )
179
+
180
+ if return_mm_token_type_ids:
181
+ array_ids = np.array(text_inputs["input_ids"])
182
+ mm_token_type_ids = np.zeros_like(text_inputs["input_ids"])
183
+ mm_token_type_ids[array_ids == self.image_token_id] = 1
184
+ mm_token_type_ids[array_ids == self.video_token_id] = 2
185
+ text_inputs["mm_token_type_ids"] = mm_token_type_ids.tolist()
186
+
187
+ return BatchFeature(
188
+ data={**text_inputs, **image_inputs, **videos_inputs},
189
+ tensor_type=return_tensors,
190
+ )
jina_v5_omni_tokenizer/processor_config.json ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "processor_class": "LlavaEuroBertProcessor",
3
+ "auto_map": {
4
+ "AutoProcessor": "processing_llava_eurobert.LlavaEuroBertProcessor"
5
+ },
6
+ "image_processor": {
7
+ "image_processor_type": "Qwen2VLImageProcessorFast",
8
+ "do_convert_rgb": true,
9
+ "do_normalize": true,
10
+ "do_rescale": true,
11
+ "do_resize": true,
12
+ "image_mean": [
13
+ 0.5,
14
+ 0.5,
15
+ 0.5
16
+ ],
17
+ "image_std": [
18
+ 0.5,
19
+ 0.5,
20
+ 0.5
21
+ ],
22
+ "min_pixels": 262144,
23
+ "max_pixels": 1310720,
24
+ "size": {
25
+ "longest_edge": 16777216,
26
+ "shortest_edge": 65536
27
+ },
28
+ "merge_size": 2,
29
+ "patch_size": 16,
30
+ "resample": 3,
31
+ "rescale_factor": 0.00392156862745098,
32
+ "temporal_patch_size": 2
33
+ },
34
+ "video_processor": {
35
+ "video_processor_type": "Qwen3VLVideoProcessor",
36
+ "do_convert_rgb": true,
37
+ "do_normalize": true,
38
+ "do_rescale": true,
39
+ "do_resize": true,
40
+ "do_sample_frames": true,
41
+ "fps": 2,
42
+ "image_mean": [
43
+ 0.5,
44
+ 0.5,
45
+ 0.5
46
+ ],
47
+ "image_std": [
48
+ 0.5,
49
+ 0.5,
50
+ 0.5
51
+ ],
52
+ "max_frames": 768,
53
+ "min_frames": 4,
54
+ "merge_size": 2,
55
+ "patch_size": 16,
56
+ "resample": 3,
57
+ "rescale_factor": 0.00392156862745098,
58
+ "size": {
59
+ "longest_edge": 25165824,
60
+ "shortest_edge": 4096
61
+ },
62
+ "temporal_patch_size": 2
63
+ },
64
+ "image_token": "<image>",
65
+ "num_additional_image_tokens": 0,
66
+ "patch_size": null,
67
+ "vision_feature_select_strategy": null
68
+ }
jina_v5_omni_tokenizer/tokenizer.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b8135ff0f019acbce7c4b93d1fb24cb8325b5fb0d76c57bbde8101a73cba7fa9
3
+ size 17211089
jina_v5_omni_tokenizer/tokenizer_config.json ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backend": "tokenizers",
3
+ "bos_token": "<|begin_of_text|>",
4
+ "clean_up_tokenization_spaces": true,
5
+ "eos_token": "<|end_of_text|>",
6
+ "is_local": false,
7
+ "mask_token": "<|mask|>",
8
+ "max_length": null,
9
+ "model_input_names": [
10
+ "input_ids",
11
+ "attention_mask"
12
+ ],
13
+ "model_max_length": 1000000000000000019884624838656,
14
+ "pad_to_multiple_of": null,
15
+ "pad_token": "<|pad|>",
16
+ "pad_token_type_id": 0,
17
+ "padding_side": "right",
18
+ "processor_class": "LlavaEuroBertProcessor",
19
+ "tokenizer_class": "TokenizersBackend",
20
+ "auto_map": {
21
+ "AutoProcessor": "processing_llava_eurobert.LlavaEuroBertProcessor"
22
+ }
23
+ }
jina_v5_omni_tokenizer/video_preprocessor_config.json ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "do_sample_frames": false,
3
+ "fps": 2,
4
+ "min_frames": 4,
5
+ "max_frames": 32,
6
+ "size": {
7
+ "longest_edge": 12845056,
8
+ "shortest_edge": 262144
9
+ },
10
+ "patch_size": 16,
11
+ "merge_size": 2,
12
+ "temporal_patch_size": 2,
13
+ "do_convert_rgb": true,
14
+ "do_normalize": true,
15
+ "do_rescale": true,
16
+ "do_resize": true,
17
+ "image_mean": [
18
+ 0.5,
19
+ 0.5,
20
+ 0.5
21
+ ],
22
+ "image_std": [
23
+ 0.5,
24
+ 0.5,
25
+ 0.5
26
+ ],
27
+ "rescale_factor": 0.00392156862745098,
28
+ "resample": 3
29
+ }
jina_v5_omni_tokenizer/vllm_jina_v5_omni.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """vLLM implementation for jina-embeddings-v5-omni-nano / -small base models.
2
+
3
+ The base models expose:
4
+ - shared LM + vision + audio weights,
5
+ - a per-task LoRA adapter (in adapters/{task}/adapter_model.safetensors),
6
+ - a per-task PretrainedMerger (vision projection),
7
+ - a per-task audio_projector,
8
+ - per-task extra token embeddings applied to language_model.embed_tokens.
9
+
10
+ vLLM requires a concrete static model at load time, so we resolve the task from
11
+ the environment variable JINA_V5_TASK (default: retrieval). At load_weights time
12
+ we read the base safetensors + the selected adapter, merge LoRA into Q/K/V/O and
13
+ gate/up/down projections, rename the task-specific mergers/projectors/token
14
+ embeddings to their singular form, and stream the resulting state dict into the
15
+ existing LlavaEuroBertAudioForVLLMEmbedding weight loader — producing a forward
16
+ that is identical to the jinaai/jina-embeddings-v5-omni-nano-{task} variant.
17
+
18
+ One task per vLLM instance; spawn separate servers for multi-task serving.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import json
23
+ import os
24
+ from pathlib import Path
25
+ from typing import Iterable
26
+
27
+ import torch
28
+ from safetensors import safe_open
29
+
30
+ try:
31
+ # Package import — works when HF dynamic-module-loader places this
32
+ # under transformers_modules.<...>.
33
+ from .vllm_llava_eurobert_audio import LlavaEuroBertAudioForVLLMEmbedding
34
+ except ImportError:
35
+ # Top-level import — works when this dir was added to PYTHONPATH
36
+ # (e.g. by vLLM's spawn child during inspect_model_cls).
37
+ from vllm_llava_eurobert_audio import LlavaEuroBertAudioForVLLMEmbedding
38
+
39
+
40
+ _TASK_KEY_MAP = {
41
+ "retrieval": "retrieval",
42
+ "text-matching": "text_matching",
43
+ "clustering": "clustering",
44
+ "classification": "classification",
45
+ }
46
+ _ATTN_MODULES = {"q_proj", "k_proj", "v_proj", "o_proj"}
47
+ _MLP_MODULES = {"gate_proj", "up_proj", "down_proj"}
48
+
49
+
50
+ def _resolve_local_dir(model_path: str) -> Path:
51
+ if os.path.isdir(model_path):
52
+ return Path(model_path)
53
+ from huggingface_hub import snapshot_download
54
+ return Path(snapshot_download(
55
+ repo_id=model_path,
56
+ allow_patterns=["model.safetensors", "config.json", "adapters/*"],
57
+ ))
58
+
59
+
60
+ def _lora_target_key(layer_idx: int, module: str, side: str) -> str:
61
+ parent = "self_attn" if module in _ATTN_MODULES else "mlp"
62
+ return (
63
+ f"base_model.model.language_model.layers.{layer_idx}."
64
+ f"{parent}.{module}.lora_{side}.weight"
65
+ )
66
+
67
+
68
+ def _materialize_task(base_dir: Path, task: str) -> dict[str, torch.Tensor]:
69
+ task_key = _TASK_KEY_MAP[task]
70
+ lora_dir = base_dir / "adapters" / task
71
+
72
+ base_cfg = json.loads((base_dir / "config.json").read_text())
73
+ special_tokens: list[int] = base_cfg["special_token_ids"]
74
+ adapter_cfg = json.loads((lora_dir / "adapter_config.json").read_text())
75
+ scale = adapter_cfg["lora_alpha"] / adapter_cfg["r"]
76
+
77
+ with safe_open(str(base_dir / "model.safetensors"), framework="pt") as f:
78
+ base = {k: f.get_tensor(k) for k in f.keys()}
79
+ with safe_open(str(lora_dir / "adapter_model.safetensors"), framework="pt") as f:
80
+ adapter = {k: f.get_tensor(k) for k in f.keys()}
81
+
82
+ merged: dict[str, torch.Tensor] = {}
83
+
84
+ for key, tensor in base.items():
85
+ if key.startswith("language_model.layers."):
86
+ parts = key.split(".")
87
+ # language_model.layers.{i}.{self_attn|mlp}.{module}.weight
88
+ if len(parts) == 6 and parts[-1] == "weight":
89
+ layer_idx = int(parts[2])
90
+ parent = parts[3]
91
+ module = parts[4]
92
+ if (parent == "self_attn" and module in _ATTN_MODULES) or (
93
+ parent == "mlp" and module in _MLP_MODULES
94
+ ):
95
+ ak = _lora_target_key(layer_idx, module, "A")
96
+ bk = _lora_target_key(layer_idx, module, "B")
97
+ a = adapter.get(ak)
98
+ b = adapter.get(bk)
99
+ if a is not None and b is not None:
100
+ delta = (b.to(torch.float32) @ a.to(torch.float32)) * scale
101
+ tensor = (tensor.to(torch.float32) + delta).to(tensor.dtype)
102
+ merged[key] = tensor
103
+
104
+ elif key == "language_model.embed_tokens.weight":
105
+ tensor = tensor.clone()
106
+ te_key = f"task_token_embeddings.{task_key}"
107
+ te = base.get(te_key)
108
+ if te is not None:
109
+ for i, tid in enumerate(special_tokens):
110
+ tensor[tid] = te[i].to(tensor.dtype)
111
+ merged[key] = tensor
112
+
113
+ elif key.startswith("mergers."):
114
+ prefix = f"mergers.{task_key}."
115
+ if key.startswith(prefix):
116
+ merged["merger." + key[len(prefix):]] = tensor
117
+
118
+ elif key.startswith("audio_projectors."):
119
+ prefix = f"audio_projectors.{task_key}."
120
+ if key.startswith(prefix):
121
+ merged["audio_projector." + key[len(prefix):]] = tensor
122
+
123
+ elif key.startswith("task_token_embeddings."):
124
+ # Consumed into embed_tokens above.
125
+ pass
126
+
127
+ else:
128
+ merged[key] = tensor
129
+
130
+ return merged
131
+
132
+
133
+ class JinaV5OmniForVLLMEmbedding(LlavaEuroBertAudioForVLLMEmbedding):
134
+ """vLLM wrapper for the base jina-embeddings-v5-omni-{nano,small}.
135
+
136
+ Reads JINA_V5_TASK env var; merges base + adapter[task] + task components at
137
+ load time. Resulting forward equals the jinaai/jina-embeddings-v5-omni-*-{task}
138
+ task variant.
139
+ """
140
+
141
+ def __init__(self, *, vllm_config, prefix: str = ""):
142
+ super().__init__(vllm_config=vllm_config, prefix=prefix)
143
+ model = getattr(vllm_config.model_config, "model", None)
144
+ if not isinstance(model, str):
145
+ raise RuntimeError(
146
+ "JinaV5OmniForVLLMEmbedding requires a string model path; got "
147
+ f"{type(model).__name__}"
148
+ )
149
+ self._base_dir = _resolve_local_dir(model)
150
+
151
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
152
+ # Task precedence: config.task (hf_overrides) > env var. No silent
153
+ # fallback — running base+vLLM without picking a task would embed
154
+ # with the wrong adapter.
155
+ task = getattr(self.config, "task", None)
156
+ if task is None:
157
+ task = os.environ.get("JINA_V5_TASK")
158
+ if task is None:
159
+ raise ValueError(
160
+ "JinaV5OmniForVLLMEmbedding requires a task selection. Pass "
161
+ "hf_overrides={'task': X} to LLM(...) or set JINA_V5_TASK=X "
162
+ "in the environment, where X is one of "
163
+ f"{sorted(_TASK_KEY_MAP)}."
164
+ )
165
+ if task not in _TASK_KEY_MAP:
166
+ raise ValueError(
167
+ f"task must be one of {sorted(_TASK_KEY_MAP)}, got '{task}'"
168
+ )
169
+ # The streamed `weights` arg only covers base model.safetensors; we need
170
+ # the adapters too, so we materialize from disk directly and discard the
171
+ # incoming stream.
172
+ for _ in weights:
173
+ pass
174
+ materialized = _materialize_task(self._base_dir, task)
175
+ return super().load_weights(iter(materialized.items()))
jina_v5_omni_tokenizer/vllm_llava_eurobert_audio.py ADDED
@@ -0,0 +1,889 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ vLLM model implementation for LlavaEuroBertAudioForEmbedding (nano multimodal embedding).
3
+
4
+ Combines:
5
+ - Vision: Qwen3VL vision encoder + PretrainedMerger
6
+ - Audio: Qwen2_5OmniAudioEncoder (from Qwen2.5-Omni-7B) + Linear projector
7
+ - Text: LlamaModel / EuroBERT (bidirectional)
8
+
9
+ Usage:
10
+ from vllm import ModelRegistry
11
+ ModelRegistry.register_model(
12
+ "LlavaEuroBertAudioForEmbedding",
13
+ "vllm_llava_eurobert_audio:LlavaEuroBertAudioForVLLMEmbedding",
14
+ )
15
+
16
+ vllm serve /path/to/model --task embedding --trust-remote-code
17
+ """
18
+
19
+ import os
20
+ from collections.abc import Iterable, Mapping, Sequence
21
+ from pathlib import Path
22
+ from typing import Annotated, Any, Literal, TypeAlias
23
+
24
+ import numpy as np
25
+ import torch
26
+ import torch.nn as nn
27
+ from transformers import BatchFeature
28
+ from transformers.models.qwen2_5_omni.configuration_qwen2_5_omni import (
29
+ Qwen2_5OmniAudioEncoderConfig,
30
+ )
31
+ from transformers.models.qwen2_5_omni.modeling_qwen2_5_omni import Qwen2_5OmniAudioEncoder
32
+ from transformers.models.qwen3_vl.configuration_qwen3_vl import Qwen3VLVisionConfig
33
+ from transformers.models.qwen3_vl.modeling_qwen3_vl import Qwen3VLVisionModel
34
+ from transformers.models.whisper import WhisperFeatureExtractor
35
+
36
+ from vllm.config import VllmConfig
37
+ try:
38
+ # vllm >= 0.11
39
+ from vllm.config.multimodal import BaseDummyOptions
40
+ except ImportError:
41
+ # vllm < 0.11 — BaseDummyOptions didn't exist; use a lightweight stand-in
42
+ # so the signature annotation still parses.
43
+ class BaseDummyOptions: # type: ignore[no-redef]
44
+ pass
45
+ try:
46
+ # vllm < 0.11: types re-exported via vllm.inputs
47
+ from vllm.inputs import MultiModalDataDict, ModalityData
48
+ except ImportError:
49
+ # vllm >= 0.11: moved to vllm.multimodal.inputs
50
+ from vllm.multimodal.inputs import MultiModalDataDict, ModalityData
51
+ from vllm.multimodal import MULTIMODAL_REGISTRY
52
+ from vllm.multimodal.inputs import (
53
+ AudioItem,
54
+ MultiModalFieldConfig,
55
+ MultiModalKwargsItems,
56
+ )
57
+ from vllm.multimodal.parse import (
58
+ DictEmbeddingItems,
59
+ ModalityDataItems,
60
+ MultiModalDataItems,
61
+ MultiModalDataParser,
62
+ )
63
+ try:
64
+ # vllm < 0.11
65
+ from vllm.multimodal.processing import BaseDummyInputsBuilder
66
+ except ImportError:
67
+ # vllm >= 0.11: moved to vllm.multimodal.profiling
68
+ from vllm.multimodal.profiling import BaseDummyInputsBuilder
69
+ from vllm.multimodal.processing import (
70
+ BaseMultiModalProcessor,
71
+ BaseProcessingInfo,
72
+ PromptReplacement,
73
+ PromptUpdate,
74
+ )
75
+ from vllm.sequence import IntermediateTensors
76
+ from vllm.utils.tensor_schema import TensorSchema, TensorShape
77
+ from vllm.model_executor.models.interfaces import (
78
+ MultiModalEmbeddings,
79
+ SupportsMultiModal,
80
+ SupportsPP,
81
+ )
82
+ from vllm.model_executor.models.qwen2_vl import _create_qwen2vl_field_factory
83
+ from vllm.model_executor.models.utils import (
84
+ AutoWeightsLoader,
85
+ init_vllm_registered_model,
86
+ maybe_prefix,
87
+ )
88
+
89
+
90
+ # --------------------------------------------------------------------------- #
91
+ # PretrainedMerger (same architecture as HuggingFace version)
92
+ # --------------------------------------------------------------------------- #
93
+
94
+
95
+ class PretrainedMerger(nn.Module):
96
+ def __init__(self, hidden_size, out_hidden_size, spatial_merge_size=2):
97
+ super().__init__()
98
+ self.hidden_size = hidden_size * (spatial_merge_size ** 2)
99
+ self.norm = nn.LayerNorm(hidden_size, eps=1e-6)
100
+ self.linear_fc1 = nn.Linear(self.hidden_size, self.hidden_size)
101
+ self.act = nn.GELU()
102
+ self.linear_fc2 = nn.Linear(self.hidden_size, out_hidden_size)
103
+
104
+ def forward(self, x):
105
+ x = self.norm(x)
106
+ x = x.view(-1, self.hidden_size)
107
+ x = self.linear_fc2(self.act(self.linear_fc1(x)))
108
+ return x
109
+
110
+
111
+ # --------------------------------------------------------------------------- #
112
+ # Audio input schemas
113
+ # --------------------------------------------------------------------------- #
114
+
115
+
116
+ class NanoAudioFeatureInputs(TensorSchema):
117
+ type: Literal["audio_features"]
118
+ input_features: Annotated[
119
+ torch.Tensor | list[torch.Tensor],
120
+ TensorShape("na", "nmb", 3000),
121
+ ]
122
+ feature_attention_mask: Annotated[
123
+ torch.Tensor,
124
+ TensorShape("na", 3000),
125
+ ]
126
+
127
+
128
+ class NanoAudioEmbeddingInputs(TensorSchema):
129
+ type: Literal["audio_embeds"] = "audio_embeds"
130
+ audio_embeds: Annotated[
131
+ list[torch.Tensor],
132
+ TensorShape("bn", "naf", "hs", dynamic_dims={"naf"}),
133
+ ]
134
+
135
+
136
+ NanoAudioInputs: TypeAlias = NanoAudioFeatureInputs | NanoAudioEmbeddingInputs
137
+
138
+
139
+ def _get_feat_extract_output_lengths(input_lengths: torch.Tensor):
140
+ feat_lengths = (input_lengths - 1) // 2 + 1
141
+ output_lengths = (feat_lengths - 2) // 2 + 1
142
+ return feat_lengths, output_lengths
143
+
144
+
145
+ # --------------------------------------------------------------------------- #
146
+ # Processing info
147
+ # --------------------------------------------------------------------------- #
148
+
149
+
150
+ class NanoMMAudioMultiModalDataParser(MultiModalDataParser):
151
+ def __init__(self, target_sr, target_channels, expected_hidden_size=None):
152
+ super().__init__(
153
+ target_sr=target_sr,
154
+ target_channels=target_channels,
155
+ expected_hidden_size=expected_hidden_size,
156
+ )
157
+
158
+ def _parse_audio_data(
159
+ self,
160
+ data: dict[str, torch.Tensor] | ModalityData[AudioItem],
161
+ ) -> ModalityDataItems[Any, Any] | None:
162
+ if isinstance(data, dict):
163
+ return DictEmbeddingItems(
164
+ data,
165
+ modality="audio",
166
+ required_fields={"audio_embeds"},
167
+ fields_factory=lambda hf: dict(
168
+ audio_embeds=MultiModalFieldConfig.batched("audio"),
169
+ input_features=MultiModalFieldConfig.batched("audio"),
170
+ feature_attention_mask=MultiModalFieldConfig.batched("audio"),
171
+ ),
172
+ )
173
+ return super()._parse_audio_data(data)
174
+
175
+
176
+ class NanoMMProcessingInfo(BaseProcessingInfo):
177
+ def get_hf_config(self):
178
+ return self.ctx.get_hf_config()
179
+
180
+ def get_feature_extractor(self, **kwargs) -> WhisperFeatureExtractor:
181
+ return WhisperFeatureExtractor(feature_size=128)
182
+
183
+ def get_data_parser(self):
184
+ feature_extractor = self.get_feature_extractor()
185
+ return NanoMMAudioMultiModalDataParser(
186
+ target_sr=feature_extractor.sampling_rate,
187
+ target_channels=1,
188
+ expected_hidden_size=self._get_expected_hidden_size(),
189
+ )
190
+
191
+ def get_supported_mm_limits(self) -> Mapping[str, int | None]:
192
+ return {"image": None, "video": None, "audio": None}
193
+
194
+ def get_mm_max_tokens_per_item(
195
+ self,
196
+ seq_len: int,
197
+ mm_counts: Mapping[str, int] | None = None,
198
+ ) -> Mapping[str, int]:
199
+ result = {}
200
+ mm_counts = mm_counts or {}
201
+
202
+ hf_config = self.get_hf_config()
203
+ vis_cfg = hf_config.vision_config
204
+ if isinstance(vis_cfg, dict):
205
+ spatial_merge_size = vis_cfg.get("spatial_merge_size", 2)
206
+ else:
207
+ spatial_merge_size = getattr(vis_cfg, "spatial_merge_size", 2)
208
+
209
+ # Always return per-item max for all modalities — vLLM calls this
210
+ # during profiling with empty mm_counts; a missing key is treated as 0
211
+ # which causes "At most 0 video(s) may be provided" errors.
212
+ result["image"] = 256 // (spatial_merge_size ** 2)
213
+
214
+ # 32-frame videos at typical resolution produce ~7040 tokens
215
+ # (measured: 16 frames → 3520 tokens with spatial_merge_size=1).
216
+ # Cap at 64 frames worth to handle evaluation edge cases.
217
+ result["video"] = 64 * 256 // max(spatial_merge_size ** 2, 1)
218
+
219
+ feature_extractor = self.get_feature_extractor()
220
+ chunk_length = min(feature_extractor.chunk_length, 30)
221
+ audio_len = int(chunk_length * feature_extractor.sampling_rate)
222
+ hop_length = feature_extractor.hop_length
223
+ max_mel_seq_len = audio_len // hop_length
224
+ input_lengths = torch.tensor([max_mel_seq_len], dtype=torch.long)
225
+ _, output_lengths = _get_feat_extract_output_lengths(input_lengths)
226
+ result["audio"] = int(output_lengths.item())
227
+
228
+ return result
229
+
230
+
231
+ # --------------------------------------------------------------------------- #
232
+ # Dummy inputs builder
233
+ # --------------------------------------------------------------------------- #
234
+
235
+
236
+ class NanoMMDummyInputsBuilder(BaseDummyInputsBuilder[NanoMMProcessingInfo]):
237
+ def get_dummy_text(self, mm_counts: Mapping[str, int]) -> str:
238
+ text = ""
239
+ num_images = mm_counts.get("image", 0)
240
+ num_videos = mm_counts.get("video", 0)
241
+ num_audios = mm_counts.get("audio", 0)
242
+
243
+ image_token = "<image>"
244
+ video_token = "<image>"
245
+ audio_token = "<|audio_bos|><|AUDIO|><|audio_eos|>"
246
+
247
+ text += image_token * num_images
248
+ text += video_token * num_videos
249
+ text += audio_token * num_audios
250
+ return text
251
+
252
+ def get_dummy_mm_data(
253
+ self,
254
+ seq_len: int,
255
+ mm_counts: Mapping[str, int],
256
+ mm_options: Mapping[str, BaseDummyOptions],
257
+ ) -> MultiModalDataDict:
258
+ result: dict[str, Any] = {}
259
+
260
+ num_images = mm_counts.get("image", 0)
261
+ if num_images > 0:
262
+ result["image"] = self._get_dummy_images(
263
+ width=224, height=224, num_images=num_images,
264
+ overrides=mm_options.get("image"),
265
+ )
266
+
267
+ num_videos = mm_counts.get("video", 0)
268
+ if num_videos > 0:
269
+ result["video"] = self._get_dummy_videos(
270
+ width=224, height=224, num_frames=2, num_videos=num_videos,
271
+ overrides=mm_options.get("video"),
272
+ )
273
+
274
+ num_audios = mm_counts.get("audio", 0)
275
+ if num_audios > 0:
276
+ feature_extractor = self.info.get_feature_extractor()
277
+ sampling_rate = feature_extractor.sampling_rate
278
+ audio_len = feature_extractor.chunk_length * sampling_rate
279
+ result["audio"] = self._get_dummy_audios(
280
+ length=audio_len, num_audios=num_audios,
281
+ overrides=mm_options.get("audio"),
282
+ )
283
+
284
+ return result
285
+
286
+
287
+ # --------------------------------------------------------------------------- #
288
+ # Multimodal processor
289
+ # --------------------------------------------------------------------------- #
290
+
291
+
292
+ class NanoMMMultiModalProcessor(BaseMultiModalProcessor[NanoMMProcessingInfo]):
293
+ def _call_hf_processor(
294
+ self,
295
+ prompt: str,
296
+ mm_data: Mapping[str, object],
297
+ mm_kwargs: Mapping[str, Any],
298
+ tok_kwargs: Mapping[str, object],
299
+ ) -> BatchFeature:
300
+ if not isinstance(mm_data, dict):
301
+ mm_data = dict(mm_data)
302
+ audios = mm_data.pop("audios", [])
303
+ if audios:
304
+ mm_data["audio"] = audios
305
+
306
+ has_audio = bool(mm_data.get("audio", []))
307
+ has_images = bool(mm_data.get("images", []))
308
+ has_videos = bool(mm_data.get("videos", []))
309
+
310
+ if not has_audio and not has_images and not has_videos:
311
+ prompt_ids = self.info.get_tokenizer().encode(prompt)
312
+ prompt_ids = self._apply_hf_processor_tokens_only(prompt_ids)
313
+ return BatchFeature(dict(input_ids=[prompt_ids]), tensor_type="pt")
314
+
315
+ if has_audio and not has_images and not has_videos:
316
+ feature_extractor = self.info.get_feature_extractor(**mm_kwargs)
317
+ tokenizer = self.info.get_tokenizer()
318
+
319
+ audio_items = mm_data.get("audio", [])
320
+ if not isinstance(audio_items, list):
321
+ audio_items = [audio_items]
322
+
323
+ def _to_audio_array(item: object) -> np.ndarray:
324
+ if hasattr(item, "data"):
325
+ item = item.data
326
+ if isinstance(item, tuple) and len(item) >= 1:
327
+ item = item[0]
328
+ if isinstance(item, dict):
329
+ for key in ("array", "audio", "data", "samples"):
330
+ if key in item:
331
+ item = item[key]
332
+ break
333
+ if hasattr(item, "array"):
334
+ item = item.array
335
+ if hasattr(item, "audio"):
336
+ item = item.audio
337
+ arr = np.asarray(item, dtype=np.float32)
338
+ if arr.ndim > 1:
339
+ arr = arr.squeeze()
340
+ return arr
341
+
342
+ processed_audio = []
343
+ for item in audio_items:
344
+ processed_audio.append(_to_audio_array(item))
345
+
346
+ audio_features = feature_extractor(
347
+ processed_audio,
348
+ sampling_rate=feature_extractor.sampling_rate,
349
+ return_tensors="pt",
350
+ padding="max_length",
351
+ )
352
+ max_mel_len = audio_features["input_features"].shape[-1]
353
+
354
+ # Keep audio prompts aligned with torch reference path
355
+ # (audio BOS + repeated audio token + audio EOS, no added special token).
356
+ prompt_ids = tokenizer.encode(prompt, add_special_tokens=False)
357
+ prompt_ids = self._apply_hf_processor_tokens_only(prompt_ids)
358
+
359
+ feature_attention_mask = torch.zeros(
360
+ (audio_features["input_features"].shape[0], max_mel_len),
361
+ dtype=torch.long,
362
+ )
363
+ feature_attention_mask[:] = 1
364
+ output = {
365
+ "input_ids": [prompt_ids],
366
+ "input_features": audio_features["input_features"],
367
+ "feature_attention_mask": feature_attention_mask,
368
+ }
369
+ return BatchFeature(output, tensor_type="pt")
370
+
371
+ if has_audio:
372
+ feature_extractor = self.info.get_feature_extractor(**mm_kwargs)
373
+ mm_kwargs = dict(**mm_kwargs, sampling_rate=feature_extractor.sampling_rate)
374
+
375
+ if has_videos:
376
+ mm_kwargs = dict(mm_kwargs, do_sample_frames=False)
377
+
378
+ return super()._call_hf_processor(
379
+ prompt=prompt,
380
+ mm_data=mm_data,
381
+ mm_kwargs=mm_kwargs,
382
+ tok_kwargs=tok_kwargs,
383
+ )
384
+
385
+ def _get_mm_fields_config(
386
+ self,
387
+ hf_inputs: BatchFeature,
388
+ hf_processor_mm_kwargs: Mapping[str, object],
389
+ ) -> Mapping[str, MultiModalFieldConfig]:
390
+ hf_cfg = self.info.get_hf_config()
391
+ spatial_merge_size = getattr(hf_cfg.vision_config, "spatial_merge_size", 2)
392
+ fields = dict(_create_qwen2vl_field_factory(spatial_merge_size)(hf_inputs))
393
+ if "input_features" in hf_inputs:
394
+ fields["input_features"] = MultiModalFieldConfig.batched("audio")
395
+ if "feature_attention_mask" in hf_inputs:
396
+ fields["feature_attention_mask"] = MultiModalFieldConfig.batched(
397
+ "audio", keep_on_cpu=True
398
+ )
399
+ if "audio_embeds" in hf_inputs:
400
+ fields["audio_embeds"] = MultiModalFieldConfig.batched("audio")
401
+ return fields
402
+
403
+ def _get_prompt_updates(
404
+ self,
405
+ mm_items: MultiModalDataItems,
406
+ hf_processor_mm_kwargs: Mapping[str, object],
407
+ out_mm_kwargs: MultiModalKwargsItems,
408
+ ) -> Sequence[PromptUpdate]:
409
+ updates = []
410
+ hf_config = self.info.get_hf_config()
411
+ out_mm_data = out_mm_kwargs.get_data()
412
+
413
+ image_token_index = getattr(hf_config, "image_token_index", None)
414
+ audio_token_id = getattr(hf_config, "audio_token_id", None)
415
+ has_image_items = any(
416
+ key in out_mm_data for key in ("pixel_values", "image_embeds", "image_grid_thw")
417
+ )
418
+ has_video_items = any(
419
+ key in out_mm_data for key in ("pixel_values_videos", "video_embeds", "video_grid_thw")
420
+ )
421
+ has_audio_items = any(
422
+ key in out_mm_data
423
+ for key in ("audio_embeds", "input_features", "feature_attention_mask")
424
+ )
425
+
426
+ spatial_merge_size = getattr(
427
+ hf_config.vision_config, "spatial_merge_size", 2
428
+ )
429
+
430
+ def _vision_replacement(grid_thw, item_idx: int):
431
+ if grid_thw is not None:
432
+ thw = grid_thw[item_idx]
433
+ t, h, w = thw.tolist() if hasattr(thw, "tolist") else (int(thw[0]), int(thw[1]), int(thw[2]))
434
+ n = int(t) * (int(h) // spatial_merge_size) * (int(w) // spatial_merge_size)
435
+ else:
436
+ n = 1
437
+ return [image_token_index] * n
438
+
439
+ if image_token_index is not None and has_image_items:
440
+ image_grid_thw = out_mm_data.get("image_grid_thw")
441
+ updates.append(
442
+ PromptReplacement(
443
+ modality="image",
444
+ target=[image_token_index],
445
+ replacement=lambda idx: _vision_replacement(image_grid_thw, idx),
446
+ )
447
+ )
448
+
449
+ if image_token_index is not None and has_video_items:
450
+ # processing_llava_eurobert.py maps both image and video tokens to
451
+ # "<image>"; the prompt uses <image> for video too.
452
+ video_grid_thw = out_mm_data.get("video_grid_thw")
453
+ updates.append(
454
+ PromptReplacement(
455
+ modality="video",
456
+ target=[image_token_index],
457
+ replacement=lambda idx: _vision_replacement(video_grid_thw, idx),
458
+ )
459
+ )
460
+
461
+ if audio_token_id is not None and has_audio_items:
462
+ feature_attention_mask = out_mm_data.get("feature_attention_mask")
463
+ if feature_attention_mask is not None:
464
+ assert isinstance(feature_attention_mask, torch.Tensor)
465
+ _, audio_output_lens = _get_feat_extract_output_lengths(
466
+ feature_attention_mask.sum(-1)
467
+ )
468
+ audio_output_lengths = audio_output_lens.tolist()
469
+ else:
470
+ audio_output_lengths = []
471
+
472
+ def get_audio_replacement(item_idx: int):
473
+ if audio_output_lengths:
474
+ n = audio_output_lengths[item_idx]
475
+ elif "audio_embeds" in out_mm_data:
476
+ embeds = out_mm_data["audio_embeds"][item_idx]
477
+ n = embeds.shape[0]
478
+ elif "input_features" in out_mm_data:
479
+ raw_feats = out_mm_data["input_features"]
480
+ if isinstance(raw_feats, torch.Tensor):
481
+ feats = raw_feats[item_idx]
482
+ else:
483
+ feat_item = raw_feats[item_idx]
484
+ feats = feat_item.data if hasattr(feat_item, "data") else feat_item
485
+ feature_len = int(feats.shape[-1])
486
+ _, output_lengths = _get_feat_extract_output_lengths(
487
+ torch.tensor([feature_len], dtype=torch.long)
488
+ )
489
+ n = int(output_lengths.item())
490
+ else:
491
+ n = 1
492
+ return [audio_token_id] * n
493
+
494
+ updates.append(
495
+ PromptReplacement(
496
+ modality="audio",
497
+ target=[audio_token_id],
498
+ replacement=get_audio_replacement,
499
+ )
500
+ )
501
+
502
+ return updates
503
+
504
+
505
+ # --------------------------------------------------------------------------- #
506
+ # Model
507
+ # --------------------------------------------------------------------------- #
508
+
509
+
510
+ @MULTIMODAL_REGISTRY.register_processor(
511
+ NanoMMMultiModalProcessor,
512
+ info=NanoMMProcessingInfo,
513
+ dummy_inputs=NanoMMDummyInputsBuilder,
514
+ )
515
+ class LlavaEuroBertAudioForVLLMEmbedding(nn.Module, SupportsMultiModal, SupportsPP):
516
+ """vLLM model for LlavaEuroBertAudioForEmbedding (nano multimodal embedding)."""
517
+
518
+ @classmethod
519
+ def get_placeholder_str(cls, modality: str, i: int) -> str | None:
520
+ if modality == "image":
521
+ return "<image>"
522
+ if modality == "video":
523
+ return "<image>"
524
+ if modality.startswith("audio"):
525
+ return f"Audio {i}: <|audio_bos|><|AUDIO|><|audio_eos|>"
526
+ return None
527
+
528
+ def __init__(self, *, vllm_config: VllmConfig, prefix: str = ""):
529
+ super().__init__()
530
+ config = vllm_config.model_config.hf_config
531
+ self.config = config
532
+ self.audio_token_id = getattr(config, "audio_token_id", None)
533
+
534
+ vis_cfg = config.vision_config
535
+ if not isinstance(vis_cfg, Qwen3VLVisionConfig):
536
+ if hasattr(vis_cfg, "to_dict"):
537
+ d = vis_cfg.to_dict()
538
+ else:
539
+ d = dict(vis_cfg)
540
+ d.pop("model_type", None)
541
+ d.pop("transformers_version", None)
542
+ vis_cfg = Qwen3VLVisionConfig(**d)
543
+ vis_cfg.deepstack_visual_indexes = []
544
+
545
+ txt_cfg = config.text_config
546
+ if isinstance(txt_cfg, dict):
547
+ from transformers import LlamaConfig
548
+ txt_cfg = LlamaConfig(**txt_cfg)
549
+ # transformers 5.x aliases rope_scaling and rope_parameters via the
550
+ # same proxy. Assigning rope_scaling = None silently nulls
551
+ # rope_parameters too, making vLLM's get_rope fall back to
552
+ # base=10000 (default Llama) instead of the model's rope_theta.
553
+ # Set only rope_theta + rope_parameters; never touch rope_scaling.
554
+ rope_params = getattr(txt_cfg, "rope_parameters", None)
555
+ if rope_params:
556
+ rope_theta = float(rope_params.get("rope_theta", 10000.0))
557
+ clean = dict(rope_params)
558
+ clean["rope_theta"] = rope_theta
559
+ txt_cfg.rope_theta = rope_theta
560
+ txt_cfg.rope_parameters = clean
561
+ text_hidden = txt_cfg.hidden_size
562
+
563
+ aud_cfg = config.audio_config
564
+ if isinstance(aud_cfg, dict):
565
+ aud_cfg = Qwen2_5OmniAudioEncoderConfig(**aud_cfg)
566
+
567
+ spatial_merge_size = getattr(vis_cfg, "spatial_merge_size", 2)
568
+ self._spatial_merge_size = spatial_merge_size
569
+
570
+ with self._mark_tower_model(vllm_config, {"image", "video"}):
571
+ self.vision_tower = Qwen3VLVisionModel(vis_cfg)
572
+ self.vision_tower.merger = nn.Identity()
573
+ self.vision_tower.deepstack_merger_list = nn.ModuleList()
574
+ self.vision_tower.deepstack_visual_indexes = []
575
+
576
+ self.merger = PretrainedMerger(
577
+ vis_cfg.hidden_size, text_hidden, spatial_merge_size
578
+ )
579
+
580
+ with self._mark_tower_model(vllm_config, "audio"):
581
+ self.audio_tower = Qwen2_5OmniAudioEncoder(aud_cfg)
582
+ self.audio_tower.proj = nn.Identity() # fused into audio_projector
583
+ d_model = getattr(aud_cfg, "d_model", 1280)
584
+ self.audio_projector = nn.Linear(d_model, text_hidden)
585
+
586
+ self.multi_modal_projector = nn.Identity()
587
+ self.lm_head = nn.Identity()
588
+
589
+ with self._mark_language_model(vllm_config):
590
+ self.language_model = init_vllm_registered_model(
591
+ vllm_config=vllm_config,
592
+ hf_config=txt_cfg,
593
+ prefix=maybe_prefix(prefix, "language_model"),
594
+ architectures=["LlamaBidirectionalModel"],
595
+ )
596
+
597
+ self.make_empty_intermediate_tensors = (
598
+ self.language_model.make_empty_intermediate_tensors
599
+ )
600
+ self._init_audio_alignment(text_hidden, vllm_config)
601
+ # Default audio prompt path expands to:
602
+ # <audio_bos> + 750 audio tokens + <audio_eos> = 752 tokens.
603
+ self._audio_default_seq_len = 752
604
+ # Set in embed_multimodal when the batch contains audio; consumed and
605
+ # cleared in forward so the seq_len fallback never fires for text-only.
606
+ self._pending_audio_in_batch = False
607
+
608
+ def _init_audio_alignment(self, hidden_size: int, vllm_config: VllmConfig) -> None:
609
+ if self.audio_token_id is None:
610
+ return
611
+ if os.getenv("JINA_OMNI_DISABLE_AUDIO_ALIGNMENT") == "1":
612
+ return
613
+
614
+ candidate_paths: list[Path] = [Path(__file__).with_name("audio_linear_alignment.pt")]
615
+ model_path = getattr(vllm_config.model_config, "model", None)
616
+ if isinstance(model_path, str):
617
+ candidate_paths.append(Path(model_path) / "audio_linear_alignment.pt")
618
+
619
+ alignment_path = next((p for p in candidate_paths if p.exists()), None)
620
+
621
+ if alignment_path is None and isinstance(model_path, str) and "/" in model_path:
622
+ try:
623
+ from huggingface_hub import hf_hub_download
624
+ alignment_path = Path(hf_hub_download(
625
+ model_path, "audio_linear_alignment.pt",
626
+ ))
627
+ except Exception:
628
+ pass
629
+
630
+ if alignment_path is None:
631
+ return
632
+
633
+ payload = torch.load(alignment_path, map_location="cpu")
634
+ matrix = payload.get("W") if isinstance(payload, dict) else payload
635
+ if not isinstance(matrix, torch.Tensor):
636
+ return
637
+ if matrix.ndim != 2:
638
+ return
639
+ if matrix.shape[0] != hidden_size or matrix.shape[1] != hidden_size:
640
+ return
641
+
642
+ self.register_buffer(
643
+ "audio_linear_alignment", matrix.to(torch.float32), persistent=False
644
+ )
645
+
646
+ def _apply_audio_alignment(
647
+ self,
648
+ hidden_states: torch.Tensor,
649
+ input_ids: torch.Tensor | None,
650
+ positions: torch.Tensor | None,
651
+ has_audio: bool = False,
652
+ ) -> torch.Tensor:
653
+ alignment_matrix = getattr(self, "audio_linear_alignment", None)
654
+ if alignment_matrix is None:
655
+ return hidden_states
656
+ if positions is None:
657
+ return hidden_states
658
+
659
+ flat_positions = positions.reshape(-1)
660
+ if flat_positions.shape[0] != hidden_states.shape[0]:
661
+ return hidden_states
662
+ flat_input_ids = input_ids.reshape(-1) if input_ids is not None else None
663
+
664
+ seq_starts = torch.nonzero(flat_positions.eq(0), as_tuple=False).flatten()
665
+ if seq_starts.numel() == 0:
666
+ seq_starts = flat_positions.new_tensor([0])
667
+ elif seq_starts[0].item() != 0:
668
+ seq_starts = torch.cat([flat_positions.new_tensor([0]), seq_starts], dim=0)
669
+
670
+ seq_ends = torch.cat(
671
+ [seq_starts[1:], flat_positions.new_tensor([flat_positions.numel()])], dim=0
672
+ )
673
+
674
+ alignment_matrix = alignment_matrix.to(
675
+ device=hidden_states.device, dtype=torch.float32
676
+ )
677
+ aligned_hidden_states = hidden_states.float()
678
+ for start, end in zip(seq_starts.tolist(), seq_ends.tolist()):
679
+ seq_len = end - start
680
+ apply_alignment = False
681
+
682
+ if flat_input_ids is not None and self.audio_token_id is not None:
683
+ apply_alignment = bool(torch.any(flat_input_ids[start:end].eq(self.audio_token_id)))
684
+ elif has_audio:
685
+ # vLLM pooling runner passes only inputs_embeds (input_ids=None).
686
+ # Only trust the default-length marker when embed_multimodal
687
+ # actually processed audio for this batch — otherwise a text
688
+ # prompt that happens to pack to 752 tokens would be poisoned.
689
+ apply_alignment = seq_len == self._audio_default_seq_len
690
+
691
+ if apply_alignment:
692
+ aligned_hidden_states[start:end] = aligned_hidden_states[start:end] @ alignment_matrix
693
+ return aligned_hidden_states.to(hidden_states.dtype)
694
+
695
+ # ---- vision processing ---- #
696
+
697
+ def _process_image_input(self, pixel_values, image_grid_thw):
698
+ vision_output = self.vision_tower(
699
+ hidden_states=pixel_values, grid_thw=image_grid_thw
700
+ )
701
+ raw_hidden = vision_output[0] if isinstance(vision_output, tuple) else vision_output[0]
702
+
703
+ image_features = self.merger(raw_hidden)
704
+
705
+ merge = self._spatial_merge_size
706
+ tokens_per_image = []
707
+ if isinstance(image_grid_thw, list):
708
+ for t, h, w in image_grid_thw:
709
+ n = int(t) * (int(h) // merge) * (int(w) // merge)
710
+ tokens_per_image.append(n)
711
+ else:
712
+ for i in range(image_grid_thw.shape[0]):
713
+ t, h, w = image_grid_thw[i].tolist()
714
+ n = int(t) * (int(h) // merge) * (int(w) // merge)
715
+ tokens_per_image.append(n)
716
+
717
+ per_image_features = []
718
+ offset = 0
719
+ for n in tokens_per_image:
720
+ feat = image_features[offset : offset + n]
721
+ per_image_features.append(feat)
722
+ offset += n
723
+
724
+ return per_image_features
725
+
726
+ # ---- audio processing ---- #
727
+
728
+ def _parse_and_validate_audio_input(
729
+ self, **kwargs: object
730
+ ) -> NanoAudioInputs | None:
731
+ input_features = kwargs.pop("input_features", None)
732
+ audio_embeds = kwargs.pop("audio_embeds", None)
733
+ feature_attention_mask = kwargs.pop("feature_attention_mask", None)
734
+
735
+ if input_features is None and audio_embeds is None:
736
+ return None
737
+
738
+ if audio_embeds is not None:
739
+ return NanoAudioEmbeddingInputs(
740
+ type="audio_embeds", audio_embeds=audio_embeds
741
+ )
742
+
743
+ return NanoAudioFeatureInputs(
744
+ type="audio_features",
745
+ input_features=input_features,
746
+ feature_attention_mask=feature_attention_mask,
747
+ )
748
+
749
+ def _process_audio_input(
750
+ self, audio_input: NanoAudioInputs
751
+ ) -> torch.Tensor | tuple[torch.Tensor, ...]:
752
+ if audio_input["type"] == "audio_embeds":
753
+ return tuple(audio_input["audio_embeds"])
754
+
755
+ input_features = audio_input["input_features"]
756
+ feature_attention_mask = audio_input["feature_attention_mask"]
757
+
758
+ feature_lens = feature_attention_mask.sum(-1).long()
759
+ aftercnn_lens, output_lengths = (
760
+ self.audio_tower._get_feat_extract_output_lengths(feature_lens)
761
+ )
762
+
763
+ packed = input_features.permute(0, 2, 1)[feature_attention_mask.bool()].permute(1, 0)
764
+
765
+ audio_outputs = self.audio_tower(
766
+ packed, feature_lens=feature_lens, aftercnn_lens=aftercnn_lens,
767
+ )
768
+ audio_features = self.audio_projector(audio_outputs.last_hidden_state)
769
+
770
+ return torch.split(audio_features, output_lengths.tolist())
771
+
772
+ # ---- embed_multimodal ---- #
773
+
774
+ def embed_multimodal(self, **kwargs: object) -> MultiModalEmbeddings:
775
+ embeddings: list[torch.Tensor] = []
776
+
777
+ pixel_values = kwargs.pop("pixel_values", None)
778
+ image_grid_thw = kwargs.pop("image_grid_thw", None)
779
+ if pixel_values is not None and image_grid_thw is not None:
780
+ embeddings.extend(
781
+ self._process_image_input(pixel_values, image_grid_thw)
782
+ )
783
+
784
+ pixel_values_videos = kwargs.pop("pixel_values_videos", None)
785
+ video_grid_thw = kwargs.pop("video_grid_thw", None)
786
+ kwargs.pop("timestamps", None)
787
+ if pixel_values_videos is not None and video_grid_thw is not None:
788
+ embeddings.extend(
789
+ self._process_image_input(pixel_values_videos, video_grid_thw)
790
+ )
791
+
792
+ audio_input = self._parse_and_validate_audio_input(**kwargs)
793
+ if audio_input is not None:
794
+ self._pending_audio_in_batch = True
795
+ audio_embeds = self._process_audio_input(audio_input)
796
+ if isinstance(audio_embeds, tuple):
797
+ embeddings.extend(audio_embeds)
798
+ else:
799
+ embeddings.append(audio_embeds)
800
+
801
+ return embeddings if embeddings else []
802
+
803
+ # ---- forward ---- #
804
+
805
+ def forward(
806
+ self,
807
+ input_ids: torch.Tensor | None,
808
+ positions: torch.Tensor,
809
+ intermediate_tensors: IntermediateTensors | None = None,
810
+ inputs_embeds: torch.Tensor | None = None,
811
+ **kwargs: object,
812
+ ) -> torch.Tensor | IntermediateTensors:
813
+ if intermediate_tensors is not None:
814
+ inputs_embeds = None
815
+
816
+ has_audio = self._pending_audio_in_batch
817
+ self._pending_audio_in_batch = False
818
+
819
+ hidden_states = self.language_model.model(
820
+ input_ids, positions, intermediate_tensors, inputs_embeds=inputs_embeds
821
+ )
822
+ hidden_states = self._apply_audio_alignment(
823
+ hidden_states, input_ids, positions, has_audio=has_audio
824
+ )
825
+ return hidden_states
826
+
827
+ def compute_logits(self, hidden_states: torch.Tensor) -> torch.Tensor | None:
828
+ return self.language_model.compute_logits(hidden_states)
829
+
830
+ # ---- weight loading ---- #
831
+
832
+ @staticmethod
833
+ def _remap_weights(
834
+ weights: Iterable[tuple[str, torch.Tensor]],
835
+ ) -> Iterable[tuple[str, torch.Tensor]]:
836
+ for name, tensor in weights:
837
+ if name.startswith("language_model.") and not name.startswith(
838
+ "language_model.model."
839
+ ) and not name.startswith("language_model.lm_head."):
840
+ name = "language_model.model." + name[len("language_model."):]
841
+ yield name, tensor
842
+
843
+ def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
844
+ loader = AutoWeightsLoader(self)
845
+ return loader.load_weights(self._remap_weights(weights))
846
+
847
+
848
+ _IMAGE_TOKEN = "<image>"
849
+ _IMAGE_PLACEHOLDER = "<image>"
850
+ _VIDEO_PLACEHOLDER = "<image>"
851
+ _AUDIO_PLACEHOLDER = "<|audio_start|><|audio_pad|><|audio_end|>"
852
+
853
+
854
+ def _image_chat_prompt(text: str = "") -> str:
855
+ return f"<|im_start|>user\n<|vision_start|>{_IMAGE_TOKEN}<|vision_end|>{text}<|im_end|>\n"
856
+
857
+
858
+ def format_prompt(text: str = "", image=None, video=None, audio=None) -> dict:
859
+ """Build a `llm.embed(...)` request dict for jina-embeddings-v5-omni-nano.
860
+
861
+ Inserts the model's vision/audio placeholder tokens for you so callers
862
+ don't need to spell them out.
863
+
864
+ For audio, also pass ``tokenization_kwargs={"add_special_tokens": False}``
865
+ to ``llm.embed`` so that LAST-token pooling lands on `<|audio_end|>` rather
866
+ than the tokenizer's auto-appended `<|end_of_text|>`.
867
+ """
868
+ if image is not None and video is None and audio is None:
869
+ return {"prompt": _image_chat_prompt(text), "multi_modal_data": {"image": image}}
870
+
871
+ parts: list[str] = []
872
+ mm: dict = {}
873
+ if image is not None:
874
+ parts.append(_IMAGE_PLACEHOLDER)
875
+ mm["image"] = image
876
+ if video is not None:
877
+ parts.append(_VIDEO_PLACEHOLDER)
878
+ mm["video"] = video
879
+ if audio is not None:
880
+ parts.append(_AUDIO_PLACEHOLDER)
881
+ mm["audio"] = audio
882
+ req: dict = {"prompt": "".join(parts) + text}
883
+ if mm:
884
+ req["multi_modal_data"] = mm
885
+ return req
886
+
887
+
888
+ import sys as _sys
889
+ _sys.modules.setdefault("jina_v5_omni", _sys.modules[__name__])
python/compare_openai_api_vs_hf_multimodal.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import urllib.request
7
+
8
+ import numpy as np
9
+
10
+ PACKAGE_ROOT = Path(__file__).resolve().parent.parent
11
+ CASE_ROOT = PACKAGE_ROOT / "python" / "testdata" / "service_cases"
12
+ DEFAULT_API_PACKAGE_ROOT = Path("/root/yongqiang/auto_model_deployment/jina-embeddings-v5-omni-small")
13
+ DEFAULT_OUTPUT_DIR = PACKAGE_ROOT / "python" / "testdata" / "openai_api_hf_multimodal_compare"
14
+ DEFAULT_CASES = (
15
+ ("T0", "embedding_doc"),
16
+ ("T1", "red_planet_query"),
17
+ ("A", "vision_sample"),
18
+ ("B", "audio_test_chunk0_30s_wav"),
19
+ ("C", "video_visual_red_panda_openai_mp4"),
20
+ )
21
+ CASE_LABEL_NAMES = {
22
+ "T0": "text_document",
23
+ "T1": "text_query",
24
+ "A": "image",
25
+ "B": "audio",
26
+ "C": "video",
27
+ }
28
+
29
+
30
+ def log(message: str) -> None:
31
+ print(f"[compare] {message}", flush=True)
32
+
33
+
34
+ def post_json(url: str, payload: dict) -> dict:
35
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
36
+ request = urllib.request.Request(
37
+ url,
38
+ data=data,
39
+ headers={
40
+ "Content-Type": "application/json",
41
+ "Authorization": "Bearer not-needed",
42
+ },
43
+ method="POST",
44
+ )
45
+ with urllib.request.urlopen(request, timeout=600) as response:
46
+ return json.loads(response.read().decode("utf-8"))
47
+
48
+
49
+ def get_default_model(api_url: str) -> str:
50
+ with urllib.request.urlopen(api_url.rstrip("/") + "/models", timeout=60) as response:
51
+ payload = json.loads(response.read().decode("utf-8"))
52
+ data = payload.get("data") or []
53
+ if not data:
54
+ raise RuntimeError("No model found from /v1/models")
55
+ return str(data[0]["id"])
56
+
57
+
58
+ def cosine_similarity(lhs: np.ndarray, rhs: np.ndarray) -> float:
59
+ lhs64 = lhs.reshape(-1).astype(np.float64)
60
+ rhs64 = rhs.reshape(-1).astype(np.float64)
61
+ denom = (np.linalg.norm(lhs64) * np.linalg.norm(rhs64)) + 1e-12
62
+ return float(np.dot(lhs64, rhs64) / denom)
63
+
64
+
65
+ def compare_embeddings(reference: np.ndarray, output: np.ndarray) -> dict:
66
+ diff = np.abs(reference - output)
67
+ return {
68
+ "reference_shape": list(reference.shape),
69
+ "output_shape": list(output.shape),
70
+ "max_abs_diff": float(diff.max()),
71
+ "mean_abs_diff": float(diff.mean()),
72
+ "cosine_similarity": cosine_similarity(reference, output),
73
+ }
74
+
75
+
76
+ def format_tensor_preview(tensor: np.ndarray, limit: int = 8) -> str:
77
+ flat = tensor.reshape(-1).astype(np.float32)
78
+ shown = ", ".join(f"{value:.6f}" for value in flat[:limit])
79
+ suffix = ", ..." if flat.size > limit else ""
80
+ return f"[{shown}{suffix}]"
81
+
82
+
83
+ def build_api_request(model: str, case_meta: dict, api_package_root: Path) -> dict:
84
+ modality = case_meta["modality"]
85
+ prompt_name = case_meta["prompt_name"]
86
+
87
+ if modality == "text":
88
+ return {
89
+ "model": model,
90
+ "input": case_meta["text"],
91
+ "prompt_name": prompt_name,
92
+ "encoding_format": "float",
93
+ }
94
+
95
+ asset_path = api_package_root / case_meta["asset_path"]
96
+
97
+ if modality == "image":
98
+ media_part = {"type": "image_url", "image_url": {"url": str(asset_path)}}
99
+ elif modality == "audio":
100
+ media_part = {"type": "audio_url", "audio_url": {"url": str(asset_path)}}
101
+ elif modality == "video":
102
+ media_part = {"type": "video_url", "video_url": {"url": str(asset_path)}}
103
+ else:
104
+ raise ValueError(f"Unsupported modality for this script: {modality}")
105
+
106
+ return {
107
+ "model": model,
108
+ "prompt_name": prompt_name,
109
+ "encoding_format": "float",
110
+ "messages": [
111
+ {
112
+ "role": "user",
113
+ "content": [
114
+ {"type": "text", "text": case_meta.get("text_prefix", "")},
115
+ media_part,
116
+ ],
117
+ }
118
+ ],
119
+ }
120
+
121
+
122
+ def save_api_only_output(output_dir: Path, label: str, modality: str, api_embedding: np.ndarray) -> dict:
123
+ api_path = output_dir / f"{label}_{modality}_openai_api.npy"
124
+ np.save(api_path, api_embedding.astype(np.float32))
125
+ return {
126
+ "openai_api_npy": str(api_path),
127
+ }
128
+
129
+
130
+ def load_reference_embedding(case_dir: Path) -> np.ndarray:
131
+ hf_path = case_dir / "torch_embedding.npy"
132
+ if not hf_path.exists():
133
+ raise FileNotFoundError(
134
+ f"Missing cached HF reference: {hf_path}. "
135
+ "This package expects every packaged service case to include a pre-generated torch_embedding.npy reference."
136
+ )
137
+ return np.load(hf_path).astype(np.float32).reshape(1, -1)
138
+
139
+
140
+ def print_case_summary(
141
+ *,
142
+ index: int,
143
+ total: int,
144
+ label: str,
145
+ display_name: str,
146
+ case_name: str,
147
+ modality: str,
148
+ prompt_name: str,
149
+ input_desc: str,
150
+ soft_token_count: int | None,
151
+ comparison: dict,
152
+ output_preview: str,
153
+ reference_preview: str,
154
+ ) -> None:
155
+ log(
156
+ f"[{index}/{total}] {display_name} ({label}) modality={modality} "
157
+ f"prompt={prompt_name} case={case_name}"
158
+ )
159
+ if soft_token_count is not None:
160
+ log(f" soft_tokens={soft_token_count}")
161
+ log(f" input={input_desc}")
162
+ log(
163
+ " output_shape={} cosine={:.6f} mean_abs_diff={:.6f} max_abs_diff={:.6f}".format(
164
+ comparison["output_shape"],
165
+ comparison["cosine_similarity"],
166
+ comparison["mean_abs_diff"],
167
+ comparison["max_abs_diff"],
168
+ )
169
+ )
170
+ log(f" api_tensor={output_preview}")
171
+ log(f" hf_tensor={reference_preview}")
172
+
173
+
174
+ def print_final_summary(
175
+ *,
176
+ model_name: str,
177
+ api_url: str,
178
+ summary_path: Path,
179
+ api_npz_path: Path,
180
+ hf_npz_path: Path,
181
+ summary_cases: list[dict],
182
+ ) -> None:
183
+ log("done")
184
+ log(f" api_url={api_url}")
185
+ log(f" model={model_name}")
186
+ for item in summary_cases:
187
+ comparison = item["comparison"]
188
+ log(
189
+ " {} ({}) cosine={:.6f} mean_abs_diff={:.6f} max_abs_diff={:.6f}".format(
190
+ item["display_name"],
191
+ item["label"],
192
+ comparison["cosine_similarity"],
193
+ comparison["mean_abs_diff"],
194
+ comparison["max_abs_diff"],
195
+ )
196
+ )
197
+ log(f" summary_json={summary_path}")
198
+ log(f" api_npz={api_npz_path}")
199
+ log(f" hf_reference_npz={hf_npz_path}")
200
+
201
+
202
+ def main() -> None:
203
+ parser = argparse.ArgumentParser(
204
+ description="Run text and multimodal embedding comparison: board-side OpenAI-compatible API vs packaged HF reference embeddings"
205
+ )
206
+ parser.add_argument("--api-url", default="http://127.0.0.1:8000/v1", type=str)
207
+ parser.add_argument("--model", default=None, type=str)
208
+ parser.add_argument(
209
+ "--api-package-root",
210
+ type=Path,
211
+ default=DEFAULT_API_PACKAGE_ROOT,
212
+ help="Board-visible package root used to build media paths inside OpenAI API requests.",
213
+ )
214
+ parser.add_argument("--output-dir", type=Path, default=DEFAULT_OUTPUT_DIR)
215
+ args = parser.parse_args()
216
+
217
+ api_package_root = args.api_package_root
218
+ output_dir = args.output_dir.resolve()
219
+ output_dir.mkdir(parents=True, exist_ok=True)
220
+
221
+ log(f"api_url={args.api_url}")
222
+ log(f"api_package_root={api_package_root}")
223
+ log(f"reference_dir={CASE_ROOT}")
224
+
225
+ model_name = args.model or get_default_model(args.api_url)
226
+ log(f"model={model_name}")
227
+ api_npz = {}
228
+ hf_npz = {}
229
+ summary_cases = []
230
+ total_cases = len(DEFAULT_CASES)
231
+
232
+ for index, (label, case_name) in enumerate(DEFAULT_CASES, start=1):
233
+ case_dir = CASE_ROOT / case_name
234
+ case_meta = json.loads((case_dir / "meta.json").read_text(encoding="utf-8"))
235
+ display_name = CASE_LABEL_NAMES.get(label, label)
236
+ if case_meta["modality"] == "text":
237
+ text = str(case_meta["text"])
238
+ input_desc = text
239
+ else:
240
+ input_desc = str(api_package_root / case_meta["asset_path"])
241
+ payload = build_api_request(model_name, case_meta, api_package_root=api_package_root)
242
+ response = post_json(args.api_url.rstrip("/") + "/embeddings", payload)
243
+ api_embedding = np.asarray(response["data"][0]["embedding"], dtype=np.float32).reshape(1, -1)
244
+ hf_embedding = load_reference_embedding(case_dir)
245
+ comparison = compare_embeddings(hf_embedding, api_embedding)
246
+ output_preview = format_tensor_preview(api_embedding)
247
+ reference_preview = format_tensor_preview(hf_embedding)
248
+
249
+ api_npz[label] = api_embedding.astype(np.float32)
250
+ hf_npz[label] = hf_embedding.astype(np.float32)
251
+ file_paths = save_api_only_output(output_dir, label, case_meta["modality"], api_embedding)
252
+ case_summary = {
253
+ "label": label,
254
+ "display_name": display_name,
255
+ "case_name": case_name,
256
+ "modality": case_meta["modality"],
257
+ "prompt_name": case_meta["prompt_name"],
258
+ "comparison": comparison,
259
+ "output_preview": output_preview,
260
+ "reference_preview": reference_preview,
261
+ "hf_meta": {
262
+ "source": "packaged_cached_reference",
263
+ },
264
+ **file_paths,
265
+ }
266
+ summary_cases.append(case_summary)
267
+ print_case_summary(
268
+ index=index,
269
+ total=total_cases,
270
+ label=label,
271
+ display_name=display_name,
272
+ case_name=case_name,
273
+ modality=case_meta["modality"],
274
+ prompt_name=case_meta["prompt_name"],
275
+ input_desc=input_desc,
276
+ soft_token_count=case_meta.get("soft_token_count"),
277
+ comparison=comparison,
278
+ output_preview=output_preview,
279
+ reference_preview=reference_preview,
280
+ )
281
+
282
+ api_npz_path = output_dir / "openai_api_tensors_cases.npz"
283
+ hf_npz_path = output_dir / "hf_torch_tensors_cases.npz"
284
+ np.savez(api_npz_path, **api_npz)
285
+ np.savez(hf_npz_path, **hf_npz)
286
+
287
+ summary = {
288
+ "api_url": args.api_url,
289
+ "model": model_name,
290
+ "api_package_root": str(api_package_root),
291
+ "reference_dir": str(CASE_ROOT),
292
+ "labels": CASE_LABEL_NAMES,
293
+ "openai_api_npz": str(api_npz_path),
294
+ "hf_torch_npz": str(hf_npz_path),
295
+ "cases": summary_cases,
296
+ }
297
+ summary_path = output_dir / "summary.json"
298
+ summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
299
+ print_final_summary(
300
+ model_name=model_name,
301
+ api_url=args.api_url,
302
+ summary_path=summary_path,
303
+ api_npz_path=api_npz_path,
304
+ hf_npz_path=hf_npz_path,
305
+ summary_cases=summary_cases,
306
+ )
307
+
308
+
309
+ if __name__ == "__main__":
310
+ main()
python/openai_embedding_demo.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ import urllib.request
7
+
8
+
9
+ def post_json(url: str, payload: dict) -> dict:
10
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
11
+ req = urllib.request.Request(
12
+ url,
13
+ data=data,
14
+ headers={
15
+ "Content-Type": "application/json",
16
+ "Authorization": "Bearer not-needed",
17
+ },
18
+ method="POST",
19
+ )
20
+ with urllib.request.urlopen(req, timeout=600) as resp:
21
+ return json.loads(resp.read().decode("utf-8"))
22
+
23
+
24
+ def l2_norm(values: list[float]) -> float:
25
+ return math.sqrt(sum(v * v for v in values))
26
+
27
+
28
+ def main() -> None:
29
+ parser = argparse.ArgumentParser(description="OpenAI-compatible text embedding demo for axllm serve")
30
+ parser.add_argument("--model", required=True, type=str)
31
+ parser.add_argument("--api-url", default="http://127.0.0.1:8000/v1", type=str)
32
+ parser.add_argument("--prompt-name", default="query", choices=["query", "document"])
33
+ parser.add_argument("--input", action="append", default=[], help="Repeatable input text")
34
+ args = parser.parse_args()
35
+
36
+ inputs = args.input or [
37
+ "What causes the seasons on Earth?",
38
+ "Earth's axial tilt causes the seasons as the planet orbits the Sun."
39
+ ]
40
+ payload = {
41
+ "model": args.model,
42
+ "input": inputs,
43
+ "prompt_name": args.prompt_name,
44
+ "encoding_format": "float",
45
+ }
46
+ response = post_json(args.api_url.rstrip("/") + "/embeddings", payload)
47
+ print(f"inputs={len(response['data'])}")
48
+ for index, item in enumerate(response["data"]):
49
+ emb = item["embedding"]
50
+ head = " ".join(f"{value:.5f}" for value in emb[:8])
51
+ print(f"[{index}] dim={len(emb)} norm={l2_norm(emb):.6f} head={head}")
52
+
53
+
54
+ if __name__ == "__main__":
55
+ main()
python/openai_multimodal_embedding_demo.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import math
6
+ from pathlib import Path
7
+ import urllib.request
8
+
9
+
10
+ def post_json(url: str, payload: dict) -> dict:
11
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
12
+ req = urllib.request.Request(
13
+ url,
14
+ data=data,
15
+ headers={
16
+ "Content-Type": "application/json",
17
+ "Authorization": "Bearer not-needed",
18
+ },
19
+ method="POST",
20
+ )
21
+ with urllib.request.urlopen(req, timeout=600) as resp:
22
+ return json.loads(resp.read().decode("utf-8"))
23
+
24
+
25
+ def l2_norm(values: list[float]) -> float:
26
+ return math.sqrt(sum(v * v for v in values))
27
+
28
+
29
+ def build_media_part(media_type: str, media_path: str) -> dict:
30
+ if media_type == "image":
31
+ return {"type": "image_url", "image_url": {"url": media_path}}
32
+ if media_type == "audio":
33
+ return {"type": "audio_url", "audio_url": {"url": media_path}}
34
+ if media_type == "video":
35
+ return {"type": "video_url", "video_url": {"url": media_path}}
36
+ raise ValueError(f"Unsupported media_type: {media_type}")
37
+
38
+
39
+ def main() -> None:
40
+ parser = argparse.ArgumentParser(description="OpenAI-compatible multimodal embedding demo for axllm serve")
41
+ parser.add_argument("--model", required=True, type=str)
42
+ parser.add_argument("--api-url", default="http://127.0.0.1:8000/v1", type=str)
43
+ parser.add_argument("--prompt-name", default="query", choices=["query", "document"])
44
+ parser.add_argument("--media-type", required=True, choices=["image", "audio", "video"])
45
+ parser.add_argument("--media-path", required=True, type=Path)
46
+ parser.add_argument("--text", default="", type=str, help="Optional text prefix before the media item")
47
+ args = parser.parse_args()
48
+
49
+ content = [{"type": "text", "text": args.text}]
50
+ content.append(build_media_part(args.media_type, str(args.media_path.resolve())))
51
+
52
+ payload = {
53
+ "model": args.model,
54
+ "prompt_name": args.prompt_name,
55
+ "encoding_format": "float",
56
+ "messages": [
57
+ {
58
+ "role": "user",
59
+ "content": content,
60
+ }
61
+ ],
62
+ }
63
+ response = post_json(args.api_url.rstrip("/") + "/embeddings", payload)
64
+ emb = response["data"][0]["embedding"]
65
+ head = " ".join(f"{value:.5f}" for value in emb[:8])
66
+ print(f"dim={len(emb)} norm={l2_norm(emb):.6f} head={head}")
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
python/verify_axllm_service.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ from pathlib import Path
6
+ import urllib.request
7
+
8
+ import numpy as np
9
+
10
+
11
+ PACKAGE_ROOT = Path(__file__).resolve().parent.parent
12
+ CASE_ROOT = PACKAGE_ROOT / "python" / "testdata" / "service_cases"
13
+
14
+
15
+ def post_json(url: str, payload: dict) -> dict:
16
+ data = json.dumps(payload, ensure_ascii=False).encode("utf-8")
17
+ req = urllib.request.Request(
18
+ url,
19
+ data=data,
20
+ headers={
21
+ "Content-Type": "application/json",
22
+ "Authorization": "Bearer not-needed",
23
+ },
24
+ method="POST",
25
+ )
26
+ with urllib.request.urlopen(req, timeout=600) as resp:
27
+ return json.loads(resp.read().decode("utf-8"))
28
+
29
+
30
+ def get_default_model(api_url: str) -> str:
31
+ with urllib.request.urlopen(api_url.rstrip("/") + "/models", timeout=60) as resp:
32
+ payload = json.loads(resp.read().decode("utf-8"))
33
+ data = payload.get("data") or []
34
+ if not data:
35
+ raise RuntimeError("No model found from /v1/models")
36
+ return str(data[0]["id"])
37
+
38
+
39
+ def cosine_similarity(lhs: np.ndarray, rhs: np.ndarray) -> float:
40
+ lhs64 = lhs.reshape(-1).astype(np.float64)
41
+ rhs64 = rhs.reshape(-1).astype(np.float64)
42
+ denom = (np.linalg.norm(lhs64) * np.linalg.norm(rhs64)) + 1e-12
43
+ return float(np.dot(lhs64, rhs64) / denom)
44
+
45
+
46
+ def compare_embeddings(reference: np.ndarray, output: np.ndarray) -> dict:
47
+ diff = np.abs(reference - output)
48
+ return {
49
+ "reference_shape": list(reference.shape),
50
+ "max_abs_diff": float(diff.max()),
51
+ "mean_abs_diff": float(diff.mean()),
52
+ "cosine_similarity": cosine_similarity(reference, output),
53
+ }
54
+
55
+
56
+ def build_request(model: str, case_meta: dict) -> dict:
57
+ modality = case_meta["modality"]
58
+ prompt_name = case_meta["prompt_name"]
59
+ if modality == "text":
60
+ return {
61
+ "model": model,
62
+ "input": case_meta["text"],
63
+ "prompt_name": prompt_name,
64
+ "encoding_format": "float",
65
+ }
66
+
67
+ asset_path = (PACKAGE_ROOT / case_meta["asset_path"]).resolve()
68
+ if modality == "image":
69
+ media_part = {"type": "image_url", "image_url": {"url": str(asset_path)}}
70
+ elif modality == "audio":
71
+ media_part = {"type": "audio_url", "audio_url": {"url": str(asset_path)}}
72
+ elif modality == "video":
73
+ media_part = {"type": "video_url", "video_url": {"url": str(asset_path)}}
74
+ else:
75
+ raise ValueError(f"Unsupported modality: {modality}")
76
+
77
+ return {
78
+ "model": model,
79
+ "prompt_name": prompt_name,
80
+ "encoding_format": "float",
81
+ "messages": [
82
+ {
83
+ "role": "user",
84
+ "content": [
85
+ {"type": "text", "text": case_meta.get("text_prefix", "")},
86
+ media_part,
87
+ ],
88
+ }
89
+ ],
90
+ }
91
+
92
+
93
+ def load_cases(explicit_cases: list[str]) -> list[Path]:
94
+ if explicit_cases:
95
+ return [CASE_ROOT / case_name for case_name in explicit_cases]
96
+ return sorted(path for path in CASE_ROOT.iterdir() if path.is_dir())
97
+
98
+
99
+ def main() -> None:
100
+ parser = argparse.ArgumentParser(description="Compare axllm /v1/embeddings outputs against packaged HF references")
101
+ parser.add_argument("--api-url", default="http://127.0.0.1:8000/v1", type=str)
102
+ parser.add_argument("--model", default=None, type=str)
103
+ parser.add_argument("--case", action="append", default=[], help="Repeatable case name under python/testdata/service_cases")
104
+ parser.add_argument("--save-summary", type=Path, default=None)
105
+ args = parser.parse_args()
106
+
107
+ model = args.model or get_default_model(args.api_url)
108
+ cases = load_cases(args.case)
109
+
110
+ summary_cases = []
111
+ for case_dir in cases:
112
+ meta = json.loads((case_dir / "meta.json").read_text(encoding="utf-8"))
113
+ reference = np.load(case_dir / "torch_embedding.npy").astype(np.float32)
114
+ payload = build_request(model, meta)
115
+ response = post_json(args.api_url.rstrip("/") + "/embeddings", payload)
116
+ output = np.asarray(response["data"][0]["embedding"], dtype=np.float32).reshape(1, -1)
117
+ comparison = compare_embeddings(reference, output)
118
+ result = {
119
+ "case_name": meta["case_name"],
120
+ "modality": meta["modality"],
121
+ "output_shape": list(output.shape),
122
+ "l2_norm": float(np.linalg.norm(output[0])),
123
+ "comparison": comparison,
124
+ }
125
+ if "soft_token_count" in meta:
126
+ result["soft_token_count"] = int(meta["soft_token_count"])
127
+ if "used_num_frames" in meta:
128
+ result["used_num_frames"] = int(meta["used_num_frames"])
129
+ summary_cases.append(result)
130
+
131
+ summary = {
132
+ "api_url": args.api_url,
133
+ "model": model,
134
+ "num_cases": len(summary_cases),
135
+ "cases": summary_cases,
136
+ }
137
+ if args.save_summary is not None:
138
+ args.save_summary.write_text(json.dumps(summary, indent=2, ensure_ascii=False), encoding="utf-8")
139
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
140
+
141
+
142
+ if __name__ == "__main__":
143
+ main()