Skip to content

Commit f8f15f2

Browse files
Python: [BREAKING] Add Gemini Embedding 2 and vector task options (microsoft#8798)
* Python: add Gemini Embedding 2 and per-operation vector options * Python: support Enterprise chat alongside Gemini embeddings * Python: exercise default Gemini and Enterprise integration paths * Python: centralize Gemini SDK routing metadata
1 parent d4157d8 commit f8f15f2

29 files changed

Lines changed: 2222 additions & 217 deletions

‎docs/specs/feature-usage-bit-registry.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ only to approved first-party endpoints.
158158
| 56 | `openai` | OpenAI clients | `agent_framework_openai` |
159159
| 57 | `anthropic` | Anthropic clients | `agent_framework_anthropic` |
160160
| 58 | `bedrock` | AWS Bedrock clients | `agent_framework_bedrock` |
161-
| 59 | `gemini` | Gemini chat client | `agent_framework_gemini` |
161+
| 59 | `gemini` | Gemini chat and embedding clients | `agent_framework_gemini` |
162162
| 60 | `mistral` | Mistral embedding client | `agent_framework_mistral` |
163163
| 61 | `ollama` | Ollama clients | `agent_framework_ollama` |
164164
| 62 | `claude` | Claude Agent SDK agent | `agent_framework_claude` |

‎python/.env.example‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,8 @@ COPILOTSTUDIOAGENT__AGENTAPPID=""
4141
ANTHROPIC_API_KEY=""
4242
ANTHROPIC_MODEL=""
4343
# Google Gemini
44-
GEMINI_API_KEY=""
45-
GEMINI_MODEL=""
44+
GOOGLE_API_KEY=""
45+
GOOGLE_MODEL=""
4646
# Ollama
4747
OLLAMA_ENDPOINT=""
4848
OLLAMA_MODEL=""

‎python/packages/core/AGENTS.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,14 @@ The vector store API is experimental under the shared `VECTOR_STORES` feature ID
125125
deserializes results without interpreting thresholds or re-filtering returned scores. Connectors own scoring,
126126
filter execution, score thresholds (including provider-defined/default metrics), and paging. Use native backend
127127
execution where available, otherwise an explicit connector-local fallback or reject unsupported options
128+
- **Embedding request options** - `upsert` accepts either flat `embeddings_options` for
129+
all generated vector fields or `embeddings_options_by_field` keyed by logical field name,
130+
never both. `search` and `create_vector_search_tool` accept flat `embeddings_options`
131+
for local query generation. Core supplies declared field dimensions and rejects
132+
conflicting values before embedding; search ignores these options when a
133+
precomputed vector is supplied. `create_upsert_tool` and
134+
`VectorCollectionContextProvider` forward the same operation-specific settings
135+
to their generated tools.
128136
- **`create_vector_search_tool`** - Creates an agent tool from any `SupportsVectorSearch` implementation
129137
- **`create_upsert_tool` / `create_get_tool` / `create_delete_tool`** - Create agent tools for collection CRUD;
130138
upsert and delete require approval by default, while get does not. Auto-generated keys are omitted from upsert

‎python/packages/core/agent_framework/_vectors.py‎

Lines changed: 174 additions & 19 deletions
Large diffs are not rendered by default.

‎python/packages/core/agent_framework/gemini/__init__.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
_IMPORTS: dict[str, tuple[str, str]] = {
1212
"GeminiChatClient": ("agent_framework_gemini", "agent-framework-gemini"),
1313
"GeminiChatOptions": ("agent_framework_gemini", "agent-framework-gemini"),
14-
"GeminiSettings": ("agent_framework_gemini", "agent-framework-gemini"),
14+
"GeminiEmbeddingClient": ("agent_framework_gemini", "agent-framework-gemini"),
15+
"GeminiEmbeddingOptions": ("agent_framework_gemini", "agent-framework-gemini"),
1516
"GoogleGeminiSettings": ("agent_framework_gemini", "agent-framework-gemini"),
1617
"RawGeminiChatClient": ("agent_framework_gemini", "agent-framework-gemini"),
18+
"RawGeminiEmbeddingClient": ("agent_framework_gemini", "agent-framework-gemini"),
1719
"ThinkingConfig": ("agent_framework_gemini", "agent-framework-gemini"),
1820
}
1921

‎python/packages/core/agent_framework/gemini/__init__.pyi‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,21 @@
33
from agent_framework_gemini import (
44
GeminiChatClient,
55
GeminiChatOptions,
6-
GeminiSettings,
6+
GeminiEmbeddingClient,
7+
GeminiEmbeddingOptions,
78
GoogleGeminiSettings,
89
RawGeminiChatClient,
10+
RawGeminiEmbeddingClient,
911
ThinkingConfig,
1012
)
1113

1214
__all__ = [
1315
"GeminiChatClient",
1416
"GeminiChatOptions",
15-
"GeminiSettings",
17+
"GeminiEmbeddingClient",
18+
"GeminiEmbeddingOptions",
1619
"GoogleGeminiSettings",
1720
"RawGeminiChatClient",
21+
"RawGeminiEmbeddingClient",
1822
"ThinkingConfig",
1923
]

‎python/packages/core/tests/core/test_gemini_namespace.py‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,16 @@ def test_gemini_namespace_dir_lists_lazy_exports() -> None:
1313
for expected in (
1414
"GeminiChatClient",
1515
"GeminiChatOptions",
16-
"GeminiSettings",
16+
"GeminiEmbeddingClient",
17+
"GeminiEmbeddingOptions",
1718
"GoogleGeminiSettings",
1819
"RawGeminiChatClient",
20+
"RawGeminiEmbeddingClient",
1921
"ThinkingConfig",
2022
):
2123
assert expected in names
24+
assert "GeminiSettings" not in names
25+
assert "GeminiEmbeddingSettings" not in names
2226

2327

2428
def test_gemini_namespace_lazy_loads_known_attribute(monkeypatch: pytest.MonkeyPatch) -> None:

‎python/packages/core/tests/core/test_vectors.py‎

Lines changed: 204 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ class MockEmbeddingClient(BaseEmbeddingClient):
9191
def __init__(self) -> None:
9292
super().__init__()
9393
self.values: list[Any] = []
94-
self.options: EmbeddingGenerationOptions | None = None
94+
self.options: dict[str, Any] | None = None
9595

9696
async def get_embeddings(
9797
self,
@@ -100,7 +100,7 @@ async def get_embeddings(
100100
options: EmbeddingGenerationOptions | None = None,
101101
) -> GeneratedEmbeddings[list[float]]:
102102
self.values = list(values)
103-
self.options = options
103+
self.options = dict(options) if options is not None else None
104104
return GeneratedEmbeddings([Embedding(vector=[float(len(str(value))), 0.5]) for value in values])
105105

106106

@@ -737,6 +737,83 @@ async def test_collection_serializes_records_and_generates_vectors() -> None:
737737
assert embedding_client.options == {"dimensions": 2}
738738

739739

740+
async def test_upsert_embedding_options_are_merged_with_field_dimensions() -> None:
741+
embedding_client = MockEmbeddingClient()
742+
collection = MockCollection(embedding_generator=embedding_client)
743+
caller_options: dict[str, Any] = {
744+
"task_type": "RETRIEVAL_DOCUMENT",
745+
"extra_parameters": {"provider_flag": True},
746+
"dimensions": 2,
747+
}
748+
749+
await collection.upsert([Record("one", "first", "document")], embeddings_options=caller_options)
750+
751+
assert embedding_client.options == caller_options
752+
assert embedding_client.values == ["document"]
753+
assert embedding_client.options is not None
754+
embedding_client.options["extra_parameters"]["provider_flag"] = False
755+
assert caller_options["extra_parameters"] == {"provider_flag": True}
756+
assert collection.records["one"]["vector"] == [8.0, 0.5]
757+
758+
embedding_client.values.clear()
759+
with pytest.raises(ValueError, match="must match its declared dimensions"):
760+
await collection.upsert([Record("two", "second", "document")], embeddings_options={"dimensions": 3})
761+
assert embedding_client.values == []
762+
assert "two" not in collection.records
763+
764+
765+
@pytest.mark.parametrize("invalid_dimensions", [True, 2.0, None])
766+
async def test_upsert_rejects_invalid_dimensions_even_if_equal(invalid_dimensions: Any) -> None:
767+
embedding_client = MockEmbeddingClient()
768+
collection = MockCollection(embedding_generator=embedding_client)
769+
with pytest.raises(ValueError, match="must match its declared dimensions"):
770+
await collection.upsert(
771+
[Record("one", "body", "document")], embeddings_options={"dimensions": invalid_dimensions}
772+
)
773+
assert embedding_client.values == []
774+
775+
776+
async def test_upsert_field_options_are_isolated_and_prevalidated() -> None:
777+
first_client = MockEmbeddingClient()
778+
second_client = MockEmbeddingClient()
779+
definition = VectorStoreCollectionDefinition([
780+
VectorStoreField("key", name="id"),
781+
VectorStoreField("vector", name="first", dimensions=2, embedding_generator=first_client),
782+
VectorStoreField("vector", name="second", dimensions=2, embedding_generator=second_client),
783+
])
784+
handler = VectorStoreRecordHandler(dict, definition=definition)
785+
record = {"id": "one", "first": "first text", "second": "second text"}
786+
787+
await handler.serialize(record, embeddings_options={"task_type": "RETRIEVAL_DOCUMENT"})
788+
assert first_client.options == {"task_type": "RETRIEVAL_DOCUMENT", "dimensions": 2}
789+
assert second_client.options == {"task_type": "RETRIEVAL_DOCUMENT", "dimensions": 2}
790+
791+
by_field = {"first": {"task_type": "RETRIEVAL_DOCUMENT"}, "second": {"task_type": "RETRIEVAL_QUERY"}}
792+
await handler.serialize(record, embeddings_options_by_field=by_field)
793+
assert first_client.options == {"task_type": "RETRIEVAL_DOCUMENT", "dimensions": 2}
794+
assert second_client.options == {"task_type": "RETRIEVAL_QUERY", "dimensions": 2}
795+
assert by_field == {"first": {"task_type": "RETRIEVAL_DOCUMENT"}, "second": {"task_type": "RETRIEVAL_QUERY"}}
796+
797+
first_client.values.clear()
798+
second_client.values.clear()
799+
with pytest.raises(ValueError, match="second.*declared dimensions"):
800+
await handler.serialize(record, embeddings_options_by_field={"second": {"dimensions": 3}})
801+
assert first_client.values == second_client.values == []
802+
803+
with pytest.raises(ValueError, match="either embeddings_options or embeddings_options_by_field"):
804+
await handler.serialize(record, embeddings_options={}, embeddings_options_by_field={})
805+
with pytest.raises(ValueError, match="non-generated vector field"):
806+
await handler.serialize(record, embeddings_options_by_field={"missing": {}})
807+
with pytest.raises(ValueError, match="non-generated vector field"):
808+
await handler.serialize(
809+
record,
810+
generate_vectors=["first"],
811+
embeddings_options_by_field={"second": {}},
812+
)
813+
with pytest.raises(ValueError, match="require records and generated vector fields"):
814+
await handler.serialize(record, generate_vectors=False, embeddings_options={})
815+
816+
740817
async def test_collection_empty_upsert_skips_embedding_generation() -> None:
741818
embedding_client = MockEmbeddingClient()
742819
collection = MockCollection(embedding_generator=embedding_client)
@@ -1053,6 +1130,36 @@ async def test_vector_search_generates_query_vector_and_forwards_threshold() ->
10531130
assert responses[1]["score"] == 0.4
10541131

10551132

1133+
async def test_vector_search_uses_provider_options_and_declared_dimensions() -> None:
1134+
embedding_client = MockEmbeddingClient()
1135+
collection = MockCollection(embedding_generator=embedding_client)
1136+
options = {"task_type": "RETRIEVAL_QUERY"}
1137+
1138+
await collection.search("find this", embeddings_options=options)
1139+
1140+
assert embedding_client.options == {"task_type": "RETRIEVAL_QUERY", "dimensions": 2}
1141+
assert options == {"task_type": "RETRIEVAL_QUERY"}
1142+
assert collection.last_search_vector == [9.0, 0.5]
1143+
1144+
embedding_client.values.clear()
1145+
with pytest.raises(ValueError, match="must match its declared dimensions"):
1146+
await collection.search("find this", embeddings_options={"dimensions": 3})
1147+
assert embedding_client.values == []
1148+
1149+
await collection.search(vector=[1.0, 0.0], embeddings_options={"dimensions": 3, **options})
1150+
assert collection.last_search_vector == [1.0, 0.0]
1151+
assert embedding_client.values == []
1152+
1153+
await collection.search("keyword terms", vector=[0.0, 1.0], embeddings_options={"dimensions": 3})
1154+
assert collection.last_search_values == "keyword terms"
1155+
assert collection.last_search_vector == [0.0, 1.0]
1156+
assert embedding_client.values == []
1157+
1158+
await MockCollection().search(vector=[1.0, 0.0], embeddings_options=options)
1159+
with pytest.raises(ValueError, match="requires an embedding generator"):
1160+
await MockCollection().search("find this", embeddings_options=options)
1161+
1162+
10561163
async def test_keyword_hybrid_search_uses_single_search_method() -> None:
10571164
collection = MockCollection()
10581165

@@ -1333,6 +1440,44 @@ async def test_create_search_tool_returns_mapped_results() -> None:
13331440
assert result[0].text == "one:0.9"
13341441

13351442

1443+
@pytest.mark.parametrize("search_type", ["vector", "keyword_hybrid"])
1444+
async def test_create_search_tool_forwards_embedding_options(search_type: SearchType) -> None:
1445+
embedding_client = MockEmbeddingClient()
1446+
collection = MockCollection(embedding_generator=embedding_client)
1447+
options = {"task_type": "RETRIEVAL_QUERY"}
1448+
1449+
tool = create_vector_search_tool(
1450+
collection,
1451+
search_type=search_type,
1452+
embeddings_options=options,
1453+
top=1,
1454+
)
1455+
options["task_type"] = "RETRIEVAL_DOCUMENT"
1456+
1457+
await tool(query="find this")
1458+
1459+
assert embedding_client.values == ["find this"]
1460+
assert embedding_client.options == {"task_type": "RETRIEVAL_QUERY", "dimensions": 2}
1461+
assert collection.last_search_values == "find this"
1462+
assert collection.last_search_vector == [9.0, 0.5]
1463+
assert collection.last_search_type == search_type
1464+
assert set(tool.parameters()["properties"]) == {"query"}
1465+
1466+
1467+
async def test_create_search_tool_rejects_unusable_embedding_options() -> None:
1468+
tool = create_vector_search_tool(MockCollection(), embeddings_options={"task_type": "RETRIEVAL_QUERY"})
1469+
with pytest.raises(ValueError, match="requires an embedding generator"):
1470+
await tool(query="find this")
1471+
1472+
embedding_client = MockEmbeddingClient()
1473+
collection = MockCollection(embedding_generator=embedding_client)
1474+
tool = create_vector_search_tool(collection, embeddings_options={"dimensions": 3})
1475+
with pytest.raises(ValueError, match="must match its declared dimensions"):
1476+
await tool(query="find this")
1477+
assert embedding_client.values == []
1478+
assert collection.last_search_type is None
1479+
1480+
13361481
async def test_create_search_tool_supports_declared_filter_parameters() -> None:
13371482
collection = MockCollection()
13381483
collection.records["one"] = {"record_id": "one", "body": "first", "vector": [1.0, 0.0]}
@@ -2595,6 +2740,33 @@ async def test_vector_crud_tools_round_trip_records() -> None:
25952740
assert await get_tool.invoke(arguments={"keys": ["one"]}, skip_parsing=True) == {"records": []}
25962741

25972742

2743+
async def test_upsert_tool_forwards_provider_embedding_options() -> None:
2744+
embedding_client = MockEmbeddingClient()
2745+
collection = MockCollection(embedding_generator=embedding_client)
2746+
options = {"task_type": "RETRIEVAL_DOCUMENT"}
2747+
upsert_tool = create_upsert_tool(collection, embeddings_options=options)
2748+
options["task_type"] = "RETRIEVAL_QUERY"
2749+
2750+
await upsert_tool.invoke(
2751+
arguments={"records": [{"id": "one", "text": "body", "vector": "document"}]},
2752+
skip_parsing=True,
2753+
)
2754+
2755+
assert embedding_client.options == {"task_type": "RETRIEVAL_DOCUMENT", "dimensions": 2}
2756+
assert collection.records["one"]["vector"] == [8.0, 0.5]
2757+
with pytest.raises(ValueError, match="either embeddings_options or embeddings_options_by_field"):
2758+
create_upsert_tool(collection, embeddings_options={}, embeddings_options_by_field={})
2759+
2760+
by_field = {"vector": {"task_type": "RETRIEVAL_DOCUMENT"}}
2761+
by_field_tool = create_upsert_tool(collection, embeddings_options_by_field=by_field)
2762+
by_field["vector"]["task_type"] = "RETRIEVAL_QUERY"
2763+
await by_field_tool.invoke(
2764+
arguments={"records": [{"id": "two", "text": "body", "vector": "more text"}]},
2765+
skip_parsing=True,
2766+
)
2767+
assert embedding_client.options == {"task_type": "RETRIEVAL_DOCUMENT", "dimensions": 2}
2768+
2769+
25982770
async def test_vector_crud_tools_support_auto_generated_keys_when_model_can_omit_them() -> None:
25992771
definition = VectorStoreCollectionDefinition(
26002772
[
@@ -2740,6 +2912,36 @@ def test_vector_collection_context_provider_configures_tools_and_approvals() ->
27402912
assert all(tool.approval_mode == "always_require" for tool in require_all.tools)
27412913

27422914

2915+
async def test_vector_collection_context_provider_forwards_embedding_options() -> None:
2916+
embedding_client = MockEmbeddingClient()
2917+
collection = MockCollection(embedding_generator=embedding_client)
2918+
provider = VectorCollectionContextProvider(
2919+
collection,
2920+
scope_filter=None,
2921+
upsert_embeddings_options={"task_type": "RETRIEVAL_DOCUMENT"},
2922+
search_embeddings_options={"task_type": "RETRIEVAL_QUERY"},
2923+
)
2924+
tools = {tool.name: tool for tool in provider.tools}
2925+
2926+
await tools["upsert"].invoke(
2927+
arguments={"records": [{"id": "one", "text": "body", "vector": "document"}]},
2928+
skip_parsing=True,
2929+
)
2930+
assert embedding_client.options == {"task_type": "RETRIEVAL_DOCUMENT", "dimensions": 2}
2931+
2932+
await tools["search"].invoke(arguments={"query": "find this"}, skip_parsing=True)
2933+
assert embedding_client.options == {"task_type": "RETRIEVAL_QUERY", "dimensions": 2}
2934+
2935+
with pytest.raises(ValueError, match="Upsert embedding options require include_upsert_tool=True"):
2936+
VectorCollectionContextProvider(
2937+
collection, scope_filter=None, include_upsert_tool=False, upsert_embeddings_options={}
2938+
)
2939+
with pytest.raises(ValueError, match="Search embedding options require include_search_tool=True"):
2940+
VectorCollectionContextProvider(
2941+
collection, scope_filter=None, include_search_tool=False, search_embeddings_options={}
2942+
)
2943+
2944+
27432945
async def test_vector_collection_context_provider_adds_attributed_context() -> None:
27442946
collection = MockCollection(embedding_generator=MockEmbeddingClient())
27452947
details_tool = create_vector_search_tool(collection, name="search_details")

‎python/packages/gemini/AGENTS.md‎

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,25 @@
11
# Gemini Package (agent-framework-gemini)
22

3-
Integration with Google's Gemini Developer API and Vertex AI via the `google-genai` SDK.
3+
Integration with Google's Gemini Developer API and Enterprise (Vertex AI) via the `google-genai` SDK.
4+
The shared `_sdk_client.create_genai_client` resolves authentication, backend mode,
5+
and service URL for chat and embeddings.
46

57
## Core Classes
68

79
- **`RawGeminiChatClient`** - Lightweight chat client without any layers, for custom pipeline composition
810
- **`GeminiChatClient`** - Full-featured chat client with function invocation, middleware, and telemetry
911
- **`GeminiChatOptions`** - Options TypedDict for Gemini-specific parameters
10-
- **`GeminiSettings`** - Settings loaded from environment variables
11-
- **`GoogleGeminiSettings`** - SDK-standard `GOOGLE_*` settings loaded from environment variables
12+
- **`GoogleGeminiSettings`** - Shared `GOOGLE_*` environment settings for chat and embeddings
1213
- **`ThinkingConfig`** - Configuration for extended thinking
14+
- **`RawGeminiEmbeddingClient`** - Text and multimodal embeddings without telemetry
15+
- **`GeminiEmbeddingClient`** - Text and multimodal embeddings with telemetry (defaults to stable `gemini-embedding-2`)
16+
- **`GeminiEmbeddingOptions`** - Per-call embedding model, dimensions, text task, and document title
17+
18+
`GeminiEmbeddingClient` supports only `gemini-embedding-2` and `gemini-embedding-2-preview`,
19+
requiring per-call task instructions for text strings. Multimodal Google SDK `Content` or
20+
media `Part` inputs receive no task prefix, even with mixed text-and-media parts. Text-only
21+
SDK content is rejected so callers cannot bypass the task requirement. Enterprise accepts
22+
one content per request; the client splits batches there while keeping input order.
1323

1424
## Gemini-specific Options
1525

@@ -34,3 +44,15 @@ from agent_framework.gemini import GeminiChatClient
3444
client = GeminiChatClient(model="gemini-2.5-flash")
3545
response = await client.get_response([Message(role="user", contents=[Content.from_text("Hello")])])
3646
```
47+
48+
```python
49+
from agent_framework.gemini import GeminiEmbeddingClient
50+
51+
client = GeminiEmbeddingClient()
52+
try:
53+
result = await client.get_embeddings(
54+
["A document"], options={"task_type": "RETRIEVAL_DOCUMENT", "dimensions": 768}
55+
)
56+
finally:
57+
await client.close()
58+
```

0 commit comments

Comments
 (0)