Skip to content

Commit fe5d69e

Browse files
MarkDaoustcopybara-github
authored andcommitted
feat: [Python] Multimodal file search
Add embeddingModel for create file searech store Add mediaID to GroundingChunkRetrievedContext Add file_search_stores.downloadMedia PiperOrigin-RevId: 909810388
1 parent cce5398 commit fe5d69e

3 files changed

Lines changed: 304 additions & 3 deletions

File tree

google/genai/file_search_stores.py

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@
2626
from . import _api_module
2727
from . import _common
2828
from . import _extra_utils
29+
from . import _transformers as t
2930
from . import types
31+
from ._api_client import BaseApiClient
3032
from ._common import get_value_by_path as getv
3133
from ._common import set_value_by_path as setv
3234
from ._operations_converters import _UploadToFileSearchStoreOperation_from_mldev
@@ -37,6 +39,7 @@
3739

3840

3941
def _CreateFileSearchStoreConfig_to_mldev(
42+
api_client: BaseApiClient,
4043
from_object: Union[dict[str, Any], object],
4144
parent_object: Optional[dict[str, Any]] = None,
4245
) -> dict[str, Any]:
@@ -45,17 +48,25 @@ def _CreateFileSearchStoreConfig_to_mldev(
4548
if getv(from_object, ['display_name']) is not None:
4649
setv(parent_object, ['displayName'], getv(from_object, ['display_name']))
4750

51+
if getv(from_object, ['embedding_model']) is not None:
52+
setv(
53+
parent_object,
54+
['_query', 'embeddingModel'],
55+
t.t_model(api_client, getv(from_object, ['embedding_model'])),
56+
)
57+
4858
return to_object
4959

5060

5161
def _CreateFileSearchStoreParameters_to_mldev(
62+
api_client: BaseApiClient,
5263
from_object: Union[dict[str, Any], object],
5364
parent_object: Optional[dict[str, Any]] = None,
5465
) -> dict[str, Any]:
5566
to_object: dict[str, Any] = {}
5667
if getv(from_object, ['config']) is not None:
5768
_CreateFileSearchStoreConfig_to_mldev(
58-
getv(from_object, ['config']), to_object
69+
api_client, getv(from_object, ['config']), to_object
5970
)
6071

6172
return to_object
@@ -340,7 +351,9 @@ def create(
340351
'This method is only supported in the Gemini Developer client.'
341352
)
342353
else:
343-
request_dict = _CreateFileSearchStoreParameters_to_mldev(parameter_model)
354+
request_dict = _CreateFileSearchStoreParameters_to_mldev(
355+
self._api_client, parameter_model
356+
)
344357
request_url_dict = request_dict.get('_url')
345358
if request_url_dict:
346359
path = 'fileSearchStores'.format_map(request_url_dict)
@@ -843,6 +856,58 @@ def upload_to_file_search_store(
843856
response=response_dict, kwargs={}
844857
)
845858

859+
def download_media(
860+
self,
861+
*,
862+
media_id: str,
863+
config: Optional[types.DownloadMediaConfigOrDict] = None,
864+
) -> bytes:
865+
"""Downloads media using a Media ID.
866+
867+
The media_id has the format:
868+
fileSearchStores/<store>/media/<blob_id>
869+
870+
This is mapped to the DownloadMedia RPC which expects:
871+
GET /{name=fileSearchStores/*/media/*}
872+
873+
Args:
874+
media_id: The Media ID from grounding metadata.
875+
config: Optional configuration for the download.
876+
877+
Returns:
878+
bytes: The media data.
879+
"""
880+
if self._api_client.vertexai:
881+
raise ValueError(
882+
'This method is only supported in the Gemini Developer client.'
883+
)
884+
885+
clean_id = media_id.lstrip('/')
886+
if '/media/' not in clean_id:
887+
raise ValueError(
888+
f'Invalid media_id format: {media_id!r}. '
889+
'Expected format: fileSearchStores/<store>/media/<blob_id>'
890+
)
891+
892+
path = f'{clean_id}?alt=media'
893+
894+
config_model = None
895+
if config:
896+
if isinstance(config, dict):
897+
config_model = types.DownloadMediaConfig(**config)
898+
else:
899+
config_model = config
900+
901+
http_options = None
902+
if config_model and getv(config_model, ['http_options']) is not None:
903+
http_options = getv(config_model, ['http_options'])
904+
905+
data = self._api_client.download_file(
906+
path,
907+
http_options=http_options,
908+
)
909+
return data
910+
846911
def list(
847912
self, *, config: Optional[types.ListFileSearchStoresConfigOrDict] = None
848913
) -> Pager[types.FileSearchStore]:
@@ -903,7 +968,9 @@ async def create(
903968
'This method is only supported in the Gemini Developer client.'
904969
)
905970
else:
906-
request_dict = _CreateFileSearchStoreParameters_to_mldev(parameter_model)
971+
request_dict = _CreateFileSearchStoreParameters_to_mldev(
972+
self._api_client, parameter_model
973+
)
907974
request_url_dict = request_dict.get('_url')
908975
if request_url_dict:
909976
path = 'fileSearchStores'.format_map(request_url_dict)
@@ -1412,6 +1479,58 @@ async def upload_to_file_search_store(
14121479
response=response_dict, kwargs={}
14131480
)
14141481

1482+
async def download_media(
1483+
self,
1484+
*,
1485+
media_id: str,
1486+
config: Optional[types.DownloadMediaConfigOrDict] = None,
1487+
) -> bytes:
1488+
"""Downloads media using a Media ID.
1489+
1490+
The media_id has the format:
1491+
fileSearchStores/<store>/media/<blob_id>
1492+
1493+
This is mapped to the DownloadMedia RPC which expects:
1494+
GET /{name=fileSearchStores/*/media/*}
1495+
1496+
Args:
1497+
media_id: The Media ID from grounding metadata.
1498+
config: Optional configuration for the download.
1499+
1500+
Returns:
1501+
bytes: The media data.
1502+
"""
1503+
if self._api_client.vertexai:
1504+
raise ValueError(
1505+
'This method is only supported in the Gemini Developer client.'
1506+
)
1507+
1508+
clean_id = media_id.lstrip('/')
1509+
if '/media/' not in clean_id:
1510+
raise ValueError(
1511+
f'Invalid media_id format: {media_id!r}. '
1512+
'Expected format: fileSearchStores/<store>/media/<blob_id>'
1513+
)
1514+
1515+
path = f'{clean_id}?alt=media'
1516+
1517+
config_model = None
1518+
if config:
1519+
if isinstance(config, dict):
1520+
config_model = types.DownloadMediaConfig(**config)
1521+
else:
1522+
config_model = config
1523+
1524+
http_options = None
1525+
if config_model and getv(config_model, ['http_options']) is not None:
1526+
http_options = getv(config_model, ['http_options'])
1527+
1528+
data = await self._api_client.async_download_file(
1529+
path,
1530+
http_options=http_options,
1531+
)
1532+
return data
1533+
14151534
async def list(
14161535
self, *, config: Optional[types.ListFileSearchStoresConfigOrDict] = None
14171536
) -> AsyncPager[types.FileSearchStore]:
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
import io
16+
import os
17+
import time
18+
import pydantic
19+
from ... import types
20+
from .. import pytest_helper
21+
22+
23+
class MultimodalFlowParams(pydantic.BaseModel):
24+
display_name: str
25+
query: str
26+
text_content: str
27+
image_relative_path: str
28+
29+
30+
test_table: list[pytest_helper.TestTableItem] = [
31+
pytest_helper.TestTableItem(
32+
name='test_multimodal_search_flow',
33+
parameters=MultimodalFlowParams(
34+
display_name='test-multimodal-store',
35+
query=(
36+
'Find the photo of the dog in the park, what is the dog doing?'
37+
),
38+
text_content='This is a test text file content for file search.',
39+
image_relative_path='../data/dog.jpg',
40+
),
41+
exception_if_vertex='supported',
42+
)
43+
]
44+
45+
pytestmark = pytest_helper.setup(
46+
file=__file__,
47+
globals_for_file=globals(),
48+
test_method='multimodal_search_flow',
49+
test_table=test_table,
50+
http_options={
51+
'api_version': 'v1beta',
52+
'base_url': (
53+
'https://autopush-generativelanguage.sandbox.googleapis.com'
54+
),
55+
},
56+
)
57+
58+
59+
def multimodal_search_flow(client, parameters: MultimodalFlowParams):
60+
# 1. Create Store
61+
store = None
62+
try:
63+
store = client.file_search_stores.create(
64+
config=types.CreateFileSearchStoreConfig(
65+
display_name=parameters.display_name,
66+
embedding_model='models/gemini-embedding-2-preview',
67+
)
68+
)
69+
70+
# 2. Upload Text
71+
text_file = io.BytesIO(parameters.text_content.encode('utf-8'))
72+
op_text = client.file_search_stores.upload_to_file_search_store(
73+
file_search_store_name=store.name,
74+
file=text_file,
75+
config=types.UploadToFileSearchStoreConfig(mime_type='text/plain'),
76+
)
77+
78+
original_cwd = os.getcwd()
79+
try:
80+
# cd is necessary because the recorder records the file path, so we need to use a relative path here.
81+
os.chdir(os.path.dirname(__file__))
82+
op_image = client.file_search_stores.upload_to_file_search_store(
83+
file_search_store_name=store.name,
84+
file=parameters.image_relative_path,
85+
config=types.UploadToFileSearchStoreConfig(mime_type='image/png'),
86+
)
87+
finally:
88+
os.chdir(original_cwd)
89+
90+
# 4. Wait for operations
91+
# In replay mode, these might be fast or pre-recorded.
92+
# In live mode, we need to poll.
93+
while not op_text.done:
94+
time.sleep(1)
95+
op_text = client.operations.get(op_text)
96+
97+
if op_image:
98+
while not op_image.done:
99+
time.sleep(1)
100+
op_image = client.operations.get(op_image)
101+
102+
# 5. Search
103+
response = client.models.generate_content(
104+
model='gemini-2.5-flash',
105+
contents=parameters.query,
106+
config=types.GenerateContentConfig(
107+
tools=[
108+
types.Tool(
109+
file_search=types.FileSearch(
110+
file_search_store_names=[store.name]
111+
)
112+
)
113+
]
114+
),
115+
)
116+
117+
# Verify response has grounding metadata
118+
assert response.candidates[0].grounding_metadata is not None
119+
120+
# 6. Download Media
121+
# Extract Media ID from grounding chunks if available
122+
blob_media_id = None
123+
if response.candidates[0].grounding_metadata.grounding_chunks:
124+
for chunk in response.candidates[0].grounding_metadata.grounding_chunks:
125+
if chunk.retrieved_context and chunk.retrieved_context.media_id:
126+
blob_media_id = chunk.retrieved_context.media_id
127+
break
128+
129+
# If we are on MLDev, we expect a Media ID and should be able to download it.
130+
if not client.vertexai:
131+
if not blob_media_id:
132+
raise ValueError('No media_id found in grounding metadata to test download.')
133+
content = client.file_search_stores.download_media(
134+
media_id=blob_media_id
135+
)
136+
assert content is not None
137+
else:
138+
# On Vertex, we expect download_media to fail if we call it.
139+
with pytest_helper.exception_if_vertex(client, ValueError):
140+
if blob_media_id:
141+
client.file_search_stores.download_media(media_id=blob_media_id)
142+
finally:
143+
if store:
144+
client.file_search_stores.delete(
145+
name=store.name, config=types.DeleteFileSearchStoreConfig(force=True)
146+
)

google/genai/types.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6955,6 +6955,10 @@ class GroundingChunkRetrievedContext(_common.BaseModel):
69556955
default=None,
69566956
description="""Optional. Page number of the retrieved context. This field is not supported in Vertex AI.""",
69576957
)
6958+
media_id: Optional[str] = Field(
6959+
default=None,
6960+
description="""Optional. Media ID. This field is not supported in Vertex AI.""",
6961+
)
69586962

69596963

69606964
class GroundingChunkRetrievedContextDict(TypedDict, total=False):
@@ -6988,6 +6992,9 @@ class GroundingChunkRetrievedContextDict(TypedDict, total=False):
69886992
page_number: Optional[int]
69896993
"""Optional. Page number of the retrieved context. This field is not supported in Vertex AI."""
69906994

6995+
media_id: Optional[str]
6996+
"""Optional. Media ID. This field is not supported in Vertex AI."""
6997+
69916998

69926999
GroundingChunkRetrievedContextOrDict = Union[
69937000
GroundingChunkRetrievedContext, GroundingChunkRetrievedContextDict
@@ -15201,6 +15208,12 @@ class CreateFileSearchStoreConfig(_common.BaseModel):
1520115208
description="""The human-readable display name for the file search store.
1520215209
""",
1520315210
)
15211+
embedding_model: Optional[str] = Field(
15212+
default=None,
15213+
description="""The embedding model to use for the FileSearchStore.
15214+
Format: `models/{model}`. If not specified, the default embedding model will be used.
15215+
""",
15216+
)
1520415217

1520515218

1520615219
class CreateFileSearchStoreConfigDict(TypedDict, total=False):
@@ -15213,6 +15226,11 @@ class CreateFileSearchStoreConfigDict(TypedDict, total=False):
1521315226
"""The human-readable display name for the file search store.
1521415227
"""
1521515228

15229+
embedding_model: Optional[str]
15230+
"""The embedding model to use for the FileSearchStore.
15231+
Format: `models/{model}`. If not specified, the default embedding model will be used.
15232+
"""
15233+
1521615234

1521715235
CreateFileSearchStoreConfigOrDict = Union[
1521815236
CreateFileSearchStoreConfig, CreateFileSearchStoreConfigDict
@@ -21205,3 +21223,21 @@ def from_api_response(
2120521223

2120621224
response_dict = _UploadToFileSearchStoreOperation_from_mldev(api_response)
2120721225
return cls._from_response(response=response_dict, kwargs={})
21226+
21227+
21228+
class DownloadMediaConfig(_common.BaseModel):
21229+
"""Used to override the default configuration."""
21230+
21231+
http_options: Optional[HttpOptions] = Field(
21232+
default=None, description="""Used to override HTTP request options."""
21233+
)
21234+
21235+
21236+
class DownloadMediaConfigDict(TypedDict, total=False):
21237+
"""Used to override the default configuration."""
21238+
21239+
http_options: Optional[HttpOptionsDict]
21240+
"""Used to override HTTP request options."""
21241+
21242+
21243+
DownloadMediaConfigOrDict = Union[DownloadMediaConfig, DownloadMediaConfigDict]

0 commit comments

Comments
 (0)