Skip to content

Python: Add async context managers and ownership close to embedding clients - #9815

Open
Wilson_Jiang (Wilson0511) wants to merge 2 commits into
microsoft:mainfrom
Wilson0511:feat/embedding-client-lifecycle
Open

Wilson_Jiang (Wilson0511) wants to merge 2 commits into
microsoft:mainfrom
Wilson0511:feat/embedding-client-lifecycle

Conversation

@Wilson0511

Copy link
Copy Markdown

Fixes #9814

Description

#9748 gave chat clients deterministic lifecycle but left the embedding client family without any of it: BaseEmbeddingClient has no context trio or close(), and the OpenAI, Bedrock, and Ollama embedding clients never release the SDK clients they create internally (httpx pool / bedrock-runtime client / httpx transport) — only GC ever closes them. The Gemini embedding client already implements close(); this brings the rest of the family to the same contract:

  • BaseEmbeddingClient gains the same __aenter__/__aexit__/close() trio Python: Add async context managers to chat clients #9748 added to BaseChatClient: no-op default close, caller-usable without a context, sync close() overrides still accepted on exit.
  • RawOpenAIEmbeddingClient / RawBedrockEmbeddingClient / RawOllamaEmbeddingClient track _owns_client (true only when the SDK client was created internally) and close() releases exactly that client, guarded by _client_closed for idempotency. Caller-supplied clients, sessions, and credentials stay open. Bedrock closes its sync boto3 client via asyncio.to_thread, mirroring Python: Add async context managers to chat clients #9748's chat client. Ollama closes the underlying httpx transport because ollama<0.6 has no AsyncClient.aclose (the constructor already reads the same private attribute).
  • Gemini needed no change: its existing close() now also gets async with support from the base trio.

RegressionCheck

  • New tests fail on main and pass here: test_base_embedding_client_default_lifecycle, test_base_embedding_client_context_calls_close (core); test_openai_embedding_client_context_ownership (openai); test_bedrock_embedding_client_context_ownership (bedrock); test_ollama_embedding_client_context_ownership (ollama). Ownership tests parametrize injected vs internally-created SDK clients and assert: context exit + explicit close() close only the owned client (await count 1 vs 0), the SDK client ends closed only when owned, and _owns_client/_client_closed do not leak into to_dict().
  • Full suites on this branch: tests/ for core, openai, bedrock, ollama — failure sets byte-identical to main at 4a272d2 (pre-existing environment failures only; zero new). openai/bedrock/ollama suites fully green including the new tests.

🤖 Generated with ZCode
Co-Authored-By: ZCode noreply@zcode.dev

…lients

microsoft#9748 gave chat clients deterministic lifecycle (context enter/exit,
close, connector-owned resource release) but left the embedding client
family without any of it: BaseEmbeddingClient has no __aenter__/
__aexit__/close, and the OpenAI, Bedrock, and Ollama embedding clients
never release the SDK clients they create internally. A client built
with default settings owns an AsyncOpenAI httpx pool (or a Bedrock
runtime client, or an Ollama httpx transport) that only GC ever closes,
and callers cannot use 'async with' at all. The Gemini embedding client
already implemented close(); this brings the rest of the family to the
same contract:

- BaseEmbeddingClient gains the same context trio as BaseChatClient,
  with a no-op default close() and sync-close tolerance.
- OpenAI, Bedrock, and Ollama embedding clients track _owns_client and
  close only the SDK client they created, leaving caller-supplied
  clients/credentials open; idempotent via _client_closed. Ollama closes
  the underlying httpx transport because ollama<0.6 has no AsyncClient
  aclose.

🤖 Generated with ZCode
Co-Authored-By: ZCode <noreply@zcode.dev>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Embedding lifecycle tests do not verify cleanup call counts for owned versus injected clients or cover legacy synchronous close overrides on normal and exceptional exits.

2 open issues
What changed in this PR

Extends the deterministic lifecycle introduced for chat clients in #9748 to Python embedding clients, addressing #9814 while preserving caller-owned resources.

Changes:

  • Adds async context management and a default no-op close() to BaseEmbeddingClient.
  • Adds ownership-aware, idempotent cleanup for OpenAI, Bedrock, and Ollama embedding clients.
  • Adds regression tests for lifecycle behavior, resource ownership, and serialization.
File Description
python/​packages/​openai/​tests/​openai/​test_openai_embedding_client.py Tests owned versus injected SDK-client cleanup.
python/​packages/​openai/​agent_framework_openai/​_embedding_client.py Tracks ownership and closes internally created SDK clients.
python/​packages/​ollama/​tests/​ollama/​test_ollama_embedding_client.py Tests transport cleanup and injected-client preservation.
python/​packages/​ollama/​agent_framework_ollama/​_embedding_client.py Adds ownership-aware HTTP transport cleanup.
python/​packages/​core/​tests/​core/​test_clients.py Tests default embedding lifecycle and context-exit cleanup.
python/​packages/​core/​agent_framework/​_clients.py Defines the shared embedding-client lifecycle contract.
python/​packages/​bedrock/​tests/​test_bedrock_client.py Tests runtime-client ownership and repeated cleanup.
python/​packages/​bedrock/​agent_framework_bedrock/​_embedding_client.py Closes owned runtime clients off the event loop.

Review effort: Balanced

Comment on lines +1466 to +1478
@pytest.mark.parametrize("error", [None, RuntimeError("body failed")])
async def test_base_embedding_client_context_calls_close(error: BaseException | None) -> None:
client = _MockEmbeddingClient()
with patch.object(client, "close", new_callable=AsyncMock) as close:
if error is None:
async with client:
close.assert_not_awaited()
else:
with pytest.raises(type(error)) as raised:
async with client:
raise error
assert raised.value is error
close.assert_awaited_once_with()

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added test_base_embedding_client_context_accepts_sync_close in the follow-up commit, mirroring the chat-client test (sync close() returning None, both normal and exceptional exits).

Comment on lines +198 to +201
async with client as entered:
assert entered is client
await client.close()
assert client.client._client.is_closed is not injected

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in the follow-up commit: the Ollama test now wraps the transport's aclose() and asserts await_count == (0 if injected else 1), so removing the _client_closed guard would fail it; is_closed is kept as a secondary state assertion.

Review follow-up on microsoft#9815: add the synchronous-close context-exit test
for BaseEmbeddingClient (both normal and exceptional exits), and make the
Ollama ownership test assert aclose() call counts so the idempotency
guard is actually pinned (is_closed alone stays true on double close).

🤖 Generated with ZCode
Co-Authored-By: ZCode <noreply@zcode.dev>
@eavanvalkenburg

Copy link
Copy Markdown
Member

Please fix the merge conflict Wilson_Jiang (@Wilson0511)

This branch had an error being deployed

1 failed deployment
github-app-auth — bef0a1b5 Deployed Oct 29, 2026 by Wilson0511 via add_label #26468
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

python Usage: [Issues, PRs], Target: Python

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Python: Embedding clients have no close()/context manager and leak their internally created SDK clients

3 participants