From 9d0c4d9b06ad28ea826b0687d7a68d59d3241170 Mon Sep 17 00:00:00 2001 From: Tommaso Bolis Date: Sun, 25 Oct 2026 00:09:13 +0200 Subject: [PATCH] fix(adk): carry donkey.last_call into after_model_callback on google-adk 2.13 (#1207) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit google-adk 2.13.0 iterates the model's response generator in a copy of the flow's context (_stream_in_caller_scope), so the record the transport set stayed in that copy and an after_model_callback read UNOBSERVED. donkey.adk.gemini() and donkey.adk.model() now return a lazily defined subclass of Gemini / LiteLlm (GovernedGemini / GovernedLiteLlm) whose generate_content_async, a plain def, opens a LastCallBridge in the flow's context before ADK copies it. The bridge closes before the first complete (non-partial) response, because ADK runs that response's function calls, in tasks of their own, before resuming the generator (hazard #2). isinstance against the ADK classes still holds, so verify_frameworks.py's expected-class check passes. Works on 2.4.0 (floor), 2.12.0 (lock) and 2.13.0; no version ceiling (§8.4). Tests: model() Runner + after_model_callback, a streamed Runner run, a task started on a complete response, and isinstance. The Runner tests fail on 2.13.0 without the fix; the task test fails without the close-before-complete step. Docs (§0.3): verified-apis.md §8 Google ADK rows record the 2.13.0 offline re-check, and fix the native Gemini row's "replaces wholesale" wording: 2.13's Gemini._with_adk_http_options merges ADK's tracking headers into the caller's http_options and fills only unset fields. adk.mdx updated to match; llms artifacts regenerated. Refs #1207 Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/verified-apis.md | 4 +- python/src/donkey_kit/integrations/adk.py | 82 +++++++++++- .../unit/test_adapter_async_last_call.py | 124 +++++++++++++++++- website/content/frameworks/adk.mdx | 20 ++- website/public/frameworks/adk.md | 20 ++- website/public/llms-full.txt | 20 ++- 6 files changed, 244 insertions(+), 26 deletions(-) diff --git a/docs/verified-apis.md b/docs/verified-apis.md index 9572c36d..a1e51514 100644 --- a/docs/verified-apis.md +++ b/docs/verified-apis.md @@ -710,8 +710,8 @@ regardless of which issue/PR produced the offline pass. | Framework | Symbol / kwarg | Status | Verified value | Date | Source | |---|---|---|---|---|---| | LangGraph | `langchain_openai.ChatOpenAI(model, base_url, api_key, default_headers, http_async_client, http_client, max_retries=0, callbacks, use_responses_api=False)` | VERIFIED (LIVE) | **Live round-trip 2026-10-13** (#34) with `langgraph==1.2.15`, `langchain-openai==1.7.1`, `langchain-core==1.6.10`, `openai==3.29.0`, on the published `donkey-kit==0.1.3.dev0` (TestPyPI), Python 3.12: `ChatOpenAI.ainvoke` on the default `use_responses_api=True` (`POST /responses`) against `ddk-openai-model-routing` (`openai/gpt-5-mini`), and, in acceptance group 7, a one-node `StateGraph` turn with `use_responses_api=False` on `/chat/completions` against `ddk-pii-masking`, a single `azureopenai/gpt-5-mini` route. Offline: construction and `isinstance` against the recorded path confirmed offline with `langgraph==1.2.12`, `langchain-openai==1.6.6`, `langchain-core==1.6.5`. Re-checked offline 2026-10-14 (#1128, #1129, #1130) on the PR lock `langgraph==1.2.15`, `langchain-openai==1.7.1`, `langchain-core==1.6.10`, `openai==3.31.0`, `pydantic==2.14.1`, Python 3.12: `verify_frameworks.py --only langgraph` reports the signature ok; the `interrupt()` composition tests behind §8.2 pass; `nox -s langgraph-demo` (3.11) passes with conformance 4/4. langchain-openai 1.7.1 only adds `mid_conversation_*` fields to the resolved model profile (`mid_conversation_tools` is forced off unless the Responses API is used); the adapter reads no profile and passes the same constructor kwargs. The deep adapter (#198) defaults to `use_responses_api=False` (`/chat/completions`) since #1043, the one route every upstream behind an OpenAI-format proxy serves (§2, route matrix #894); `chat_model(..., use_responses_api=True)` opts into the live-verified `/responses` data plane. Also tested offline with `openai==2.54.0`. Re-checked offline 2026-10-16 (#1162) on the PR lock `langgraph==1.2.15`, `langchain-openai==1.7.2`, `langchain-core==1.6.11`, `openai==3.31.0`, `pydantic==2.14.1`, Python 3.12: `verify_frameworks.py --only langgraph` reports the signature ok. The `interrupt()` composition tests and `nox -s langgraph-demo` were not re-run for this bump. Re-checked offline 2026-10-19 (#1166, #1167) on the PR lock `langgraph==1.2.16`, `langchain-openai==1.7.2`, `langchain-core==1.6.12`, `openai==3.35.1`, `pydantic==2.14.1`, Python 3.12: `verify_frameworks.py --only langgraph` reports the signature ok; the whole suite passes (3300 passed, 271 skipped) and `smoke_example.py langgraph` passes the conformance kit; the `interrupt()` composition tests behind §8.2 pass (27 passed, 2 skipped in the langgraph-demo venv on Python 3.11, where the opentelemetry-gated ones run); `nox -s langgraph-demo` (3.11) passes with conformance 4/4. langgraph 1.2.16 leaves `interrupt()`, `Command` and `GraphInterrupt` byte-identical to 1.2.15 (diff of the installed source); its resume fixes (#9322, #9265) touch pending-write bookkeeping and the invalid-resume handling for `response_schema` validation, which `approval_interrupt()` does not pass, so the adapter is unchanged. langchain-core 1.6.12 (content-block-start extras, #41272) is in `language_models/_compat_bridge.py`; the adapter imports only `langchain_core.callbacks.BaseCallbackHandler`. This was not re-checked live; the live round-trip above is unchanged. | 2026-10-13 | #34; `python/scripts/verify_frameworks.py --only langgraph` (also `--emit-verified`); `python/scripts/verify_frameworks.py --only langgraph --live`; acceptance group 7 | -| Google ADK | `google.adk.models.lite_llm.LiteLlm(model="openai/…", api_base, api_key, extra_headers, max_retries=0, client, llm_client)` | VERIFIED (LIVE) | **Live round-trip 2026-10-13** (#34) with `google-adk==2.11.0`, `litellm==1.105.0`, `openai==2.54.0`, on the published `donkey-kit==0.1.3.dev0` (TestPyPI), Python 3.12: an `Agent` on the adapter's `LiteLlm`, one turn through `InMemoryRunner.run_async` (`POST /chat/completions`) against `ddk-openai-model-routing` (`openai/gpt-5-mini`); acceptance group 7 also ran it on `/chat/completions` against `ddk-pii-masking`, a single `azureopenai/gpt-5-mini` route. Offline: construction and `isinstance` against the recorded path confirmed offline with `google-adk==2.10.0`, `litellm==1.102.1`. Re-checked offline 2026-10-14 with `google-adk==2.12.0`, `litellm==1.105.1`, `openai==2.54.0`, `google-genai==2.30.1`, Python 3.12 (#1139, #1140): `verify_frameworks.py --only adk` gives signature ok and expected-class ok; `LiteLlm` still has exactly `model` and `llm_client`, `LiteLLMClient.acompletion(self, model, messages, tools, **kwargs)` is unchanged and still awaited as `llm_client.acompletion(...)` for streamed and non-streamed requests; the adk/litellm tests pass, including the `max_retries=0` request-count and typed-refusal-bridge tests. The 2.12.0 `LiteLlm` fixes (tool-call argument parsing, streamed tool-call thought signatures, tool-call id handling) and litellm 1.105.x's HTTP-pool refactor (BerriAI/litellm#43245, proxy-server side) were read from the release notes only, not exercised live. The pre-built OpenAI `client` sends through the SDK's shared client (#740), so every call carries the run's correlation id and `donkey.last_call` is `OBSERVED` after a call; the live turn above confirms the consumer-auth headers reach the proxy. A refusal surfaces as a LiteLLM error (`litellm.exceptions.APIError` for a PII 403, `InternalServerError` for a transport-raised `ModelSubstituted` or `GatewayUnavailable`) with no gateway headers and no `__cause__`; confirmed offline with `google-adk==2.11.0`, `litellm==1.104.2`, `openai==2.54.0` (#969). `LiteLlm.llm_client` (a pydantic field typed `LiteLLMClient`, present from `google-adk==2.4.0`) is awaited as `llm_client.acompletion(...)` for every model request, streamed or not, so `model()` passes a `LiteLLMClient` subclass that tags LiteLLM's error with the refusal the transport recorded for that call, and the typed-refusal bridge raises it typed (`tests/conformance/test_typed_refusal_bridge.py`). Also tested offline with `openai==2.54.0`. `max_retries=0` goes to LiteLLM, which sets it on `client` per call (default 2); one send per budget 429 confirmed by request count against a loopback proxy with `litellm==1.103.2` (#734, `tests/unit/test_framework_retries.py`). Re-checked offline 2026-10-19 with `google-adk==2.12.0`, `litellm==1.106.0`, `openai==2.54.0`, `google-genai==2.31.0`, Python 3.12, on the PR lock `adapter-contract-adk-py3.12.txt` (#1170): `verify_frameworks.py --only adk` gives signature ok and expected-class ok for `adk` and `adk.gemini` (live not run); the whole suite passes (3286 passed, 228 skipped for extras not installed on this leg), including the adk/litellm tests, the `max_retries=0` request-count tests and the typed-refusal-bridge tests. litellm 1.106.0's attribution-header change (BerriAI/litellm#44291) was read in the installed source: `BaseConfig.get_attribution_headers()` returns an empty mapping by default and only the Novita and Perplexity configs override it, `with_attribution_headers` lets caller headers win on a name clash and never mutates its input, and the adapter's `extra_headers` goes to the request through `optional_params["extra_headers"]`, outside that merge; no wire capture was taken and no live round-trip was run on 1.106.0. Re-checked offline 2026-10-22 with `google-adk==2.12.0`, `litellm==1.106.2`, `openai==2.54.0`, `google-genai==2.32.0`, Python 3.12, on the PR lock `adapter-contract-adk-py3.12.txt` (#1194): `verify_frameworks.py --only adk` gives signature ok and expected-class ok for `adk` and `adk.gemini` (live not run); the whole suite passes (3411 passed, 228 skipped for extras not installed on this leg), including the adk/litellm tests, the `max_retries=0` request-count tests and the typed-refusal-bridge tests. No live round-trip was run on 1.106.2. | 2026-10-13 | #34; `python/scripts/verify_frameworks.py --only adk` (also `--emit-verified`); `python/scripts/verify_frameworks.py --only adk --live`; acceptance group 7 | -| Google ADK (native Gemini) | `google.adk.models.Gemini(model, base_url, client_kwargs={api_key, http_options={base_url, api_version="", headers, timeout, httpx_async_client}})` | VERIFIED (LIVE) | Round-trip through a `Format=Gemini` proxy with `google-adk==2.10.0`, `google-genai==2.25.0`: `generate_content_async` → 200, SSE streaming, and a tool-calling agent run. `client_kwargs` **replaces** ADK's default `http_options` wholesale, so the adapter passes the full set; an injected `httpx_async_client` disables genai's aiohttp path, so buffered and streamed calls both go through the shared `DonkeyAsyncClient`. `api_key` is required by genai and sent as `x-goog-api-key` (gateway-ignored placeholder). `timeout` is milliseconds (`None` would disable it). genai raises its own `google.genai.errors.APIError` whose `.response` is the httpx response, so `classify(exc.response)` works. `Gemini.client_kwargs` first appears in google-adk **2.4.0** (absent in 2.0.0 and 2.3.0, checked 2026-10-01); ADK's pydantic config ignores unknown fields, so on older versions the governed client is dropped silently. The `adk` extra floors at `google-adk>=2.4`, `gemini()` refuses a `Gemini` lacking either field with `_verify.blocked(...)`, and the round-trip test runs in CI at 2.4.0 and the newest release (#735). ADK caches one genai `Client` per event loop, and the shared `DonkeyAsyncClient` keeps one connection pool per loop, so one Donkey serves repeated `asyncio.run(...)` calls (#807). Signature re-checked offline 2026-10-14 with `google-adk==2.12.0`, `google-genai==2.30.1` (#1139): `Gemini` still has `base_url` and `client_kwargs`, `verify_frameworks.py --only adk` reports `adk.gemini` signature ok, and `test_adapter_contract.py::test_streamed_call[adk.gemini]` passes; no live Gemini round-trip was run on 2.12.0. | 2026-10-01 | #691, #735; `python/scripts/verify_frameworks.py --only adk.gemini --live` against `ddk-gemini-inbound` | +| Google ADK | `google.adk.models.lite_llm.LiteLlm(model="openai/…", api_base, api_key, extra_headers, max_retries=0, client, llm_client)` | VERIFIED (LIVE) | **Live round-trip 2026-10-13** (#34) with `google-adk==2.11.0`, `litellm==1.105.0`, `openai==2.54.0`, on the published `donkey-kit==0.1.3.dev0` (TestPyPI), Python 3.12: an `Agent` on the adapter's `LiteLlm`, one turn through `InMemoryRunner.run_async` (`POST /chat/completions`) against `ddk-openai-model-routing` (`openai/gpt-5-mini`); acceptance group 7 also ran it on `/chat/completions` against `ddk-pii-masking`, a single `azureopenai/gpt-5-mini` route. Offline: construction and `isinstance` against the recorded path confirmed offline with `google-adk==2.10.0`, `litellm==1.102.1`. Re-checked offline 2026-10-14 with `google-adk==2.12.0`, `litellm==1.105.1`, `openai==2.54.0`, `google-genai==2.30.1`, Python 3.12 (#1139, #1140): `verify_frameworks.py --only adk` gives signature ok and expected-class ok; `LiteLlm` still has exactly `model` and `llm_client`, `LiteLLMClient.acompletion(self, model, messages, tools, **kwargs)` is unchanged and still awaited as `llm_client.acompletion(...)` for streamed and non-streamed requests; the adk/litellm tests pass, including the `max_retries=0` request-count and typed-refusal-bridge tests. The 2.12.0 `LiteLlm` fixes (tool-call argument parsing, streamed tool-call thought signatures, tool-call id handling) and litellm 1.105.x's HTTP-pool refactor (BerriAI/litellm#43245, proxy-server side) were read from the release notes only, not exercised live. The pre-built OpenAI `client` sends through the SDK's shared client (#740), so every call carries the run's correlation id and `donkey.last_call` is `OBSERVED` after a call; the live turn above confirms the consumer-auth headers reach the proxy. A refusal surfaces as a LiteLLM error (`litellm.exceptions.APIError` for a PII 403, `InternalServerError` for a transport-raised `ModelSubstituted` or `GatewayUnavailable`) with no gateway headers and no `__cause__`; confirmed offline with `google-adk==2.11.0`, `litellm==1.104.2`, `openai==2.54.0` (#969). `LiteLlm.llm_client` (a pydantic field typed `LiteLLMClient`, present from `google-adk==2.4.0`) is awaited as `llm_client.acompletion(...)` for every model request, streamed or not, so `model()` passes a `LiteLLMClient` subclass that tags LiteLLM's error with the refusal the transport recorded for that call, and the typed-refusal bridge raises it typed (`tests/conformance/test_typed_refusal_bridge.py`). Also tested offline with `openai==2.54.0`. `max_retries=0` goes to LiteLLM, which sets it on `client` per call (default 2); one send per budget 429 confirmed by request count against a loopback proxy with `litellm==1.103.2` (#734, `tests/unit/test_framework_retries.py`). Re-checked offline 2026-10-19 with `google-adk==2.12.0`, `litellm==1.106.0`, `openai==2.54.0`, `google-genai==2.31.0`, Python 3.12, on the PR lock `adapter-contract-adk-py3.12.txt` (#1170): `verify_frameworks.py --only adk` gives signature ok and expected-class ok for `adk` and `adk.gemini` (live not run); the whole suite passes (3286 passed, 228 skipped for extras not installed on this leg), including the adk/litellm tests, the `max_retries=0` request-count tests and the typed-refusal-bridge tests. litellm 1.106.0's attribution-header change (BerriAI/litellm#44291) was read in the installed source: `BaseConfig.get_attribution_headers()` returns an empty mapping by default and only the Novita and Perplexity configs override it, `with_attribution_headers` lets caller headers win on a name clash and never mutates its input, and the adapter's `extra_headers` goes to the request through `optional_params["extra_headers"]`, outside that merge; no wire capture was taken and no live round-trip was run on 1.106.0. Re-checked offline 2026-10-22 with `google-adk==2.12.0`, `litellm==1.106.2`, `openai==2.54.0`, `google-genai==2.32.0`, Python 3.12, on the PR lock `adapter-contract-adk-py3.12.txt` (#1194): `verify_frameworks.py --only adk` gives signature ok and expected-class ok for `adk` and `adk.gemini` (live not run); the whole suite passes (3411 passed, 228 skipped for extras not installed on this leg), including the adk/litellm tests, the `max_retries=0` request-count tests and the typed-refusal-bridge tests. No live round-trip was run on 1.106.2. Re-checked offline 2026-10-24 with `google-adk==2.13.0`, `litellm==1.106.2`, `openai==2.54.0`, `google-genai==2.32.0`, Python 3.12, on the PR lock `adapter-contract-adk-py3.12.txt` with only `google-adk` moved to 2.13.0 (#1207): 2.13.0 iterates the model's response generator in a copy of the flow's context (`google.adk.utils._runner_utils._stream_in_caller_scope`, read in the installed source), so the record the transport sets stayed in that copy and an `after_model_callback` read `UNOBSERVED` (`test_adk_runner_is_unobserved_but_after_model_callback_sees_it` failed). `model()` and `gemini()` now return a subclass (`GovernedLiteLlm`, `GovernedGemini`, from `_last_call_bridged_class`) whose `generate_content_async`, a plain `def`, opens a `LastCallBridge` in the flow's context before ADK copies it, and closes it before the first complete (non-partial) response, because ADK runs that response's function calls (in tasks of their own on 2.13.0) before it resumes the generator. `verify_frameworks.py --only adk --emit-verified` gives signature ok and expected-class ok for `adk` and `adk.gemini` (the class check is `isinstance`; actual class `donkey_kit.integrations.adk.GovernedLiteLlm` / `GovernedGemini`), live not run; the same at the `google-adk==2.4.0` floor. The whole suite passes on 2.13.0 and on the 2.12.0 lock (3468 passed, 228 skipped each), and `scripts/smoke_example.py adk` passes on 2.13.0. A `LiteLlm` built from `connection_kwargs()` is not bridged, so on 2.13.0 its callback reads `UNOBSERVED`. No live round-trip was run on 2.13.0. | 2026-10-13 | #34; `python/scripts/verify_frameworks.py --only adk` (also `--emit-verified`); `python/scripts/verify_frameworks.py --only adk --live`; acceptance group 7 | +| Google ADK (native Gemini) | `google.adk.models.Gemini(model, base_url, client_kwargs={api_key, http_options={base_url, api_version="", headers, timeout, httpx_async_client}})` | VERIFIED (LIVE) | Round-trip through a `Format=Gemini` proxy with `google-adk==2.10.0`, `google-genai==2.25.0`: `generate_content_async` → 200, SSE streaming, and a tool-calling agent run. ADK builds its genai `Client` from `client_kwargs`, and through google-adk 2.12.0 `client_kwargs['http_options']` **replaces** ADK's default `http_options` wholesale, so the adapter passes the full set (from 2.13.0 ADK merges into it instead; see the 2.13.0 re-check below); an injected `httpx_async_client` disables genai's aiohttp path, so buffered and streamed calls both go through the shared `DonkeyAsyncClient`. `api_key` is required by genai and sent as `x-goog-api-key` (gateway-ignored placeholder). `timeout` is milliseconds (`None` would disable it). genai raises its own `google.genai.errors.APIError` whose `.response` is the httpx response, so `classify(exc.response)` works. `Gemini.client_kwargs` first appears in google-adk **2.4.0** (absent in 2.0.0 and 2.3.0, checked 2026-10-01); ADK's pydantic config ignores unknown fields, so on older versions the governed client is dropped silently. The `adk` extra floors at `google-adk>=2.4`, `gemini()` refuses a `Gemini` lacking either field with `_verify.blocked(...)`, and the round-trip test runs in CI at 2.4.0 and the newest release (#735). ADK caches one genai `Client` per event loop, and the shared `DonkeyAsyncClient` keeps one connection pool per loop, so one Donkey serves repeated `asyncio.run(...)` calls (#807). Signature re-checked offline 2026-10-14 with `google-adk==2.12.0`, `google-genai==2.30.1` (#1139): `Gemini` still has `base_url` and `client_kwargs`, `verify_frameworks.py --only adk` reports `adk.gemini` signature ok, and `test_adapter_contract.py::test_streamed_call[adk.gemini]` passes; no live Gemini round-trip was run on 2.12.0. Re-checked offline 2026-10-24 with `google-adk==2.13.0`, `google-genai==2.32.0`, Python 3.12 (#1207): `Gemini` still has `base_url` and `client_kwargs`, and `verify_frameworks.py --only adk --emit-verified` reports `adk.gemini` signature ok and expected-class ok (`isinstance`; actual class `donkey_kit.integrations.adk.GovernedGemini`), live not run. Two 2.13.0 changes were read in the installed source. (1) `Gemini._with_adk_http_options` no longer lets `client_kwargs['http_options']` replace ADK's options wholesale: it fills only the `api_version`, `base_url` and `retry_options` the caller left unset, so the caller's fields win, and it merges ADK's tracking values into the caller's `headers` (`merge_tracking_headers`, which keeps every other header). The adapter sets `base_url` and `api_version` itself, so neither is filled. 2.12.0 already merged the tracking values per request in `generate_content_async`, so the wire is unchanged: an offline capture through `MockTransport` on 2.12.0 and 2.13.0 shows the same `POST /models/gemini-2.5-flash:generateContent` with `client_id`, `client_secret`, `x-goog-api-key` and the `x-goog-api-client`/`user-agent` tracking values, differing only in the `google-adk/` token. (2) `_stream_in_caller_scope` iterates the response generator in a copy of the flow's context, so `gemini()` returns the bridged subclass described in the Google ADK row; `test_adk_runner_is_unobserved_but_after_model_callback_sees_it` and `test_adk_streamed_run_after_model_callback_sees_every_response` fail on 2.13.0 without it and pass with it, as they do on 2.12.0 and 2.4.0. A `Gemini` built from `gemini_connection_kwargs()` is not bridged, so on 2.13.0 its callback reads `UNOBSERVED`. Offline, with the recorded `responses.stream.*` fixture through `MockTransport`, `donkey.last_call.input_tokens` stays `None` for a streamed call on 2.12.0 and 2.13.0, with or without the bridge, both in the callbacks and after the generator ends. The bridge does not change it; the cause was not investigated here, and the adk page's "usage lands on the last one" for streaming is not confirmed by this check. No live Gemini round-trip was run on 2.13.0. | 2026-10-01 | #691, #735; `python/scripts/verify_frameworks.py --only adk.gemini --live` against `ddk-gemini-inbound` | | MS Agent Framework | `agent_framework.openai.OpenAIChatClient(model, base_url, api_key, default_headers)` — the **Responses API** client (`POST /responses`), built by `chat_client(..., api="responses")`; `agent_framework.openai.OpenAIChatCompletionClient(…)` with the same kwargs — the **Chat Completions** client (`POST /chat/completions`), built by `chat_client()` by default (#1043) | VERIFIED (LIVE) | **Live round-trip 2026-10-13** (#34) with `agent-framework==1.21.0`, `agent-framework-openai==1.16.0`, `openai==3.29.0`, on the published `donkey-kit==0.1.3.dev0` (TestPyPI), Python 3.12: one `Agent.run` turn on each client — the default Chat Completions client (`POST /chat/completions`) and the `api="responses"` client (`POST /responses`) — against `ddk-openai-model-routing` (`openai/gpt-5-mini`); acceptance group 7 also ran it on `/chat/completions` against `ddk-pii-masking`, a single `azureopenai/gpt-5-mini` route (Chat Completions client). Offline: construction and `isinstance` against both recorded paths confirmed offline with `agent-framework==1.19.0` / `agent-framework-openai==1.14.4`, and re-checked at `agent-framework==1.21.0` / `agent-framework-openai==1.16.0` / `openai==3.31.0` (#1126): `verify_frameworks.py --only agent_framework` still resolves `OpenAIChatCompletionClient` and `OpenAIChatClient` with signature ok, both accept `model`, `base_url`, `api_key`, `default_headers`, `async_client` and neither accepts `model_id`. The constructor kwarg is `model=` — `model_id` is **not** accepted. `base_url`/`api_key`/`default_headers`/`async_client` are accepted by both, so `connection_kwargs()` serves either. Both chain the openai error as `__cause__` of `ChatClientException` (`OpenAIContentFilterException` for a content-filter 400), so `policy_middleware()` covers both. The default is `/chat/completions` (#1043), the only route every upstream serves (§2 per-upstream route matrix, #894); `/responses` is the opt-in for OpenAI-routed proxies. The switch traces back to an Azure OpenAI route that answered `/responses` with **404** `Resource not found` and `/chat/completions` with 200 in one live probe of `ddk-azure-openai-model-routing` (2026-09-30, #826); re-captured 2026-10-07 as the §2 fixture `src/donkey_kit/simulator/_fixtures/anypoint/azure_openai_routing/` (#896, moved in #895); the `/chat/completions` route itself is recorded in §2 (Chat Completions row). Also confirmed offline at the `agent-framework==1.13.0` extras floor, where the call reaches the proxy (`test_framework_redirects.py`). 1.12.0 constructs but sends elsewhere (§8.3, #743). (Reclassified from a bare `VERIFIED` per #681 — same offline-only evidence class as its peer rows; §0.3 reserves `VERIFIED` for a real-sandbox round-trip.) Re-checked offline 2026-10-16 at `agent-framework==1.22.0` / `agent-framework-openai==1.17.0` / `openai==3.31.0` (#1156): `verify_frameworks.py --only agent_framework` resolves `agent_framework_openai._chat_completion_client.OpenAIChatCompletionClient` and `agent_framework_openai._chat_client.OpenAIChatClient` with signature ok and expected-class ok; the constructor kwarg set was not re-listed. The live round-trip was not re-run. | 2026-10-13 | #34, #520, #826 (`verify_frameworks.py --only agent_framework`); `python/scripts/verify_frameworks.py --only agent_framework --live`; acceptance group 7 | | MS Agent Framework (middleware) | `agent_framework.chat_middleware` decorator; `ChatContext.stream` / `.result`; `ResponseStream.with_pull_context_manager(cm_factory)`; `agent_framework.exceptions.ChatClientException` chaining the openai error as `__cause__` | UNVERIFIED (signature confirmed; pending maintainer `--live` + sign-off) | Confirmed offline with `agent-framework==1.19.0`: `Agent(middleware=[...])` accepts a `@chat_middleware`-marked function (an undecorated `(context, next)` function whose first parameter is not annotated `ChatContext` raises `MiddlewareException: Cannot determine middleware type`). `OpenAIChatClient._handle_request_error` re-raises every openai error as `ChatClientException` from the original, so `policy_middleware()` runs the call inside the typed-refusal bridge (`core/refusals.py`, #724), whose chain walk reaches the `openai.APIStatusError` and classifies its response in core; the adapter's `refusal_translator` sees through `agent_framework.exceptions.AgentFrameworkException` (the base of `ChatClientException`, confirmed offline with `agent-framework-core==1.20.0`), so an unreachable gateway surfaces as `GatewayUnavailable`. A streamed refusal surfaces on the caller's pull, after the middleware has returned; `with_pull_context_manager` wraps each pull, so the same conversion applies. Pinned by `test_agent_framework_policy_middleware.py` on a real `Agent` (one send, typed `PIIDetected` with the run's correlation id, streaming and non-streaming). Re-checked at `agent-framework==1.21.0` / `agent-framework-core==1.21.0` (#1126): the `agent-framework-middleware` session passes, `ResponseStream.with_pull_context_manager(cm_factory)` and `chat_middleware` are unchanged, `ChatClientException` still subclasses `AgentFrameworkException`, and the two streaming fixes in 1.21.0 (delegated provider streams close on release, microsoft/agent-framework#9100; streamed logprobs kept across empty chunks, #8997) change cleanup ordering and one metadata field, not the pull-context wrapping or the exception chain. No live round-trip. Re-checked at `agent-framework==1.22.0` / `agent-framework-core==1.22.0` (#1156): the `agent-framework-middleware` session's test files pass (96 passed, 55 skipped for other frameworks, 0 failed); the 1.22.0 release notes and API surface were not otherwise inspected. | 2026-10-16 | #739, #1126, #1156 | | OpenAI Agents SDK | `agents.OpenAIChatCompletionsModel(model, openai_client=openai.AsyncOpenAI(base_url, api_key, default_headers, http_client, max_retries=0))` | VERIFIED (LIVE) | **Live round-trip 2026-10-13** (#34) with `openai-agents==0.23.1`, `openai==3.29.0`, on the published `donkey-kit==0.1.3.dev0` (TestPyPI), Python 3.12: an `Agent` on the adapter's model, one turn through `Runner.run` (`POST /chat/completions`) against `ddk-openai-model-routing` (`openai/gpt-5-mini`); acceptance group 7 also ran it on `/chat/completions` against `ddk-pii-masking`, a single `azureopenai/gpt-5-mini` route. Offline: construction and `isinstance` against the recorded path confirmed offline with `openai-agents==0.20.0`. Also tested offline with `openai==2.54.0`. Re-checked offline 2026-10-14 (#1142) at `openai==3.31.0` with `openai-agents==0.23.1` (the PR lock): `verify_frameworks.py --only openai_agents` resolves `agents.models.openai_chatcompletions.OpenAIChatCompletionsModel` with the signature ok; `openai.AsyncOpenAI.__init__` still takes `base_url`, `api_key`, `default_headers`, `http_client` (typed `httpx2.AsyncClient | None`) and `max_retries`; the adapter-contract suite and the strict example smoke pass. openai 3.30.0 drains an async HTTP/1.1 stream for at most 0.1 s after `[DONE]` (openai/openai-python#4041); through the bridge the forwarded stream is already consumed, so the drain hits `httpx.StreamConsumed`, which openai catches, and the response is closed rather than pooled. Streams complete with no added latency or hang, as at 3.24.0. The live round-trip was not re-run. Re-checked offline 2026-10-16 (#223, promotion to a deep, conformance-gated adapter) at `openai-agents==0.24.0` with `openai==3.31.0` (the new `openai-agents-demo` lock): `verify_frameworks.py --only openai_agents` resolves the class with the signature ok; the installed constructor is `OpenAIChatCompletionsModel(model, openai_client, should_replay_reasoning_content=None, strict_feature_validation=False, buffer_streamed_tool_calls=False)`, and the adapter passes only `model` and `openai_client`. The demo additionally relies on the framework's own documented `agents.Agent(name, instructions, model, tools)`, `agents.Runner.run(starting_agent, input, *, run_config=…)`, `agents.RunConfig(tracing_disabled=True)` and `agents.RunResult.final_output`, all confirmed present at 0.24.0; `Agent.instructions` accepts a `(RunContextWrapper, Agent) -> str` callable. The four public conformance scenarios and the Runner-level correlation test (`tests/unit/test_openai_agents_correlation.py`) pass against it. The live round-trip was not re-run. Re-checked offline 2026-10-19 (#1171) at `openai==3.35.1` with `openai-agents==0.24.0` (the PR lock, Python 3.12, installed with the adapter-contract recipe): `verify_frameworks.py --only openai_agents` resolves `agents.models.openai_chatcompletions.OpenAIChatCompletionsModel` with the signature ok; `openai.AsyncOpenAI.__init__` still takes `base_url`, `api_key`, `default_headers`, `http_client` (typed `httpx2.AsyncClient | None`) and `max_retries`, and `openai.DefaultAsyncHttpxClient` is still built on `httpx2.AsyncClient`; the full suite (3266 passed, 273 skipped for other frameworks' extras) and the strict example smoke pass. The live round-trip was not re-run. | 2026-10-13 | #34; #223; `python/scripts/verify_frameworks.py --only openai_agents` (also `--emit-verified`); `python/scripts/verify_frameworks.py --only openai_agents --live`; acceptance group 7 | diff --git a/python/src/donkey_kit/integrations/adk.py b/python/src/donkey_kit/integrations/adk.py index 0acb6c8b..26827acd 100644 --- a/python/src/donkey_kit/integrations/adk.py +++ b/python/src/donkey_kit/integrations/adk.py @@ -28,22 +28,32 @@ work. The default DDK proxies are ``Format=OpenAI``, so point ``base_url`` at a ``Format=Gemini`` proxy. +Both factories return a subclass of the ADK class that carries +``donkey.last_call`` back to the context that started the model call (#1207). +google-adk 2.13.0 runs each step of the model's response generator in a copy of +the flow's context, so without it the record stays in that copy and an +``after_model_callback`` reads ``UNOBSERVED``. + Class names / kwargs UNVERIFIED — docs/verified-apis.md §8. """ from __future__ import annotations import functools +from contextlib import aclosing from types import MappingProxyType from typing import TYPE_CHECKING, Any from ..core import _verify +from ..core.lastcall import LastCallBridge, open_last_call_bridge from ..core.masking import masked from ..core.refusals import capture_refusals from . import AdapterCapabilities from ._base import Adapter, default_adapter if TYPE_CHECKING: + from collections.abc import AsyncGenerator + from google.adk.models import Gemini from google.adk.models.lite_llm import LiteLlm, LiteLLMClient @@ -116,7 +126,8 @@ def gemini_connection_kwargs(self, *, base_url: str | None = None) -> dict[str, yourself, bound to a ``Format=Gemini`` proxy (``base_url``, default the configured proxy URL). - ADK replaces its own ``http_options`` with ``client_kwargs``, so every + ADK builds its client from ``client_kwargs`` (from google-adk 2.13.0 it + adds its tracking headers and fills only the fields left unset), so every governed value rides there: the shared http client (header injection, retries, last_call), the consumer-auth headers, the ``api_key`` slot google-genai requires for the Gemini API backend, and the SDK timeout — @@ -149,7 +160,11 @@ def model(self, model: str, **kw: Any) -> LiteLlm: Its ``llm_client`` is a ``LiteLLMClient`` subclass that lets a gateway refusal reach ``donkey.run()`` as its typed class (#969, see the module docstring). An ``llm_client`` you pass replaces it, and the refusal - then stays LiteLLM's error.""" + then stays LiteLLM's error. + + The object is a ``LiteLlm`` subclass that carries ``donkey.last_call`` + back to the context that started each model call, so an + ``after_model_callback`` reads it (#1207).""" self._allow_endpoints(kw, "api_base", "base_url") with self._native_import(): from google.adk.models.lite_llm import ( # VERIFY name/path: docs/verified-apis.md §8 @@ -163,13 +178,18 @@ def model(self, model: str, **kw: Any) -> LiteLlm: # LiteLLM sends to the client's own base URL, so rebuild it there. conn["client"] = self._proxy_openai_client(str(override)) conn["llm_client"] = _governed_llm_client_class(LiteLLMClient)() + governed: type[LiteLlm] = _last_call_bridged_class(LiteLlm) # LiteLLM's OpenAI-compatible route needs the ``openai/`` prefix. - return LiteLlm(model=f"openai/{model}", **{**conn, **kw}) + return governed(model=f"openai/{model}", **{**conn, **kw}) def gemini(self, model: str, *, base_url: str | None = None, **kw: Any) -> Gemini: """Return ADK's native ``google.adk.models.Gemini`` bound to a ``Format=Gemini`` proxy, with the shared http client injected (#691). - Pass the bare model id (``"gemini-2.5-flash"``) — no provider prefix.""" + Pass the bare model id (``"gemini-2.5-flash"``) — no provider prefix. + + The object is a ``Gemini`` subclass that carries ``donkey.last_call`` + back to the context that started each model call, so an + ``after_model_callback`` reads it (#1207, see the module docstring).""" conn = self.gemini_connection_kwargs(base_url=base_url) with self._native_import(): from google.adk.models import Gemini # VERIFY name/path: docs/verified-apis.md §8 @@ -189,7 +209,59 @@ def gemini(self, model: str, *, base_url: str | None = None, **kw: Any) -> Gemin "older versions ADK drops the governed client without an error. " "Upgrade with: pip install -U 'donkey-kit[adk]'" ) - return Gemini(model=model, **{**conn, **kw}) + governed: type[Gemini] = _last_call_bridged_class(Gemini) + return governed(model=model, **{**conn, **kw}) + + +@functools.cache +def _last_call_bridged_class(base: Any) -> Any: + """The subclass of an ADK model class (``base``: ``Gemini`` or ``LiteLlm``) + that :meth:`ADKAdapter.gemini` and :meth:`ADKAdapter.model` build, defined + on first use so ``google.adk`` is imported lazily (§1.1). + + Only the public ``BaseLlm.generate_content_async`` is overridden. ADK's flow + calls it in its own context and then iterates the generator it returns. + From google-adk 2.13.0 it iterates that generator in a copy of the context + (``_stream_in_caller_scope``), so the record the transport sets stays in the + copy, and the ``after_model_callback`` that ADK runs in the flow's context + reads ``UNOBSERVED`` (docs/verified-apis.md §8). The override is a plain + ``def``, so it opens a :class:`~donkey_kit.core.lastcall.LastCallBridge` + in the flow's context before ADK takes the copy. The copy inherits the + bridge, and the transport records through it. + + The bridge serves one model call in one task, so hazard #2 holds: a + ``ParallelAgent`` branch runs in a task of its own and opens its own bridge. + It closes before the generator yields a complete (non-partial) response, + because ADK runs that response's function calls before it resumes the + generator, and a tool that ADK runs in a task of its own must not write + through the bridge. A partial response keeps it open, since ADK runs no + function calls for one.""" + + class LastCallBridged(base): + """An ADK model whose ``donkey.last_call`` reaches the context that + started the call (#1207).""" + + def generate_content_async(self, *args: Any, **kwargs: Any) -> AsyncGenerator[Any, None]: + bridge = open_last_call_bridge() + return _bridged_responses(super().generate_content_async(*args, **kwargs), bridge) + + LastCallBridged.__name__ = LastCallBridged.__qualname__ = f"Governed{base.__name__}" + return LastCallBridged + + +async def _bridged_responses( + responses: AsyncGenerator[Any, None], bridge: LastCallBridge +) -> AsyncGenerator[Any, None]: + """Yield ``responses`` and close ``bridge`` before the first complete + response, or when the call ends early (see :func:`_last_call_bridged_class`).""" + try: + async with aclosing(responses): + async for response in responses: + if not getattr(response, "partial", False): + bridge.close() + yield response + finally: + bridge.close() @functools.cache diff --git a/python/tests/unit/test_adapter_async_last_call.py b/python/tests/unit/test_adapter_async_last_call.py index 1e89fbdd..9bbfe268 100644 --- a/python/tests/unit/test_adapter_async_last_call.py +++ b/python/tests/unit/test_adapter_async_last_call.py @@ -14,13 +14,19 @@ * The OpenAI Agents SDK ``Runner`` sends a run's first turn from a task of its own, and ADK's ``Runner`` runs the whole agent in one. Neither runs anything the adapter controls in the caller's context, so there is nowhere to open a - :class:`~donkey_kit.core.lastcall.LastCallBridge`. Bridging the whole run - instead would break hazard #2, since a run can fan out (agents as tools, ADK's - ``ParallelAgent``). The gap is documented on the framework pages, with the - in-task hook that reads the record (``RunHooks.on_llm_end``, + :class:`~donkey_kit.core.lastcall.LastCallBridge` for the caller. Bridging the + whole run instead would break hazard #2, since a run can fan out (agents as + tools, ADK's ``ParallelAgent``). The gap is documented on the framework pages, + with the in-task hook that reads the record (``RunHooks.on_llm_end``, ``after_model_callback``). The ``UNOBSERVED`` assertions below pin that documented behaviour: if one starts failing, the framework changed where it sends the call, and the page needs updating. +* From google-adk 2.13.0, ADK's flow iterates the model's response generator in + a copy of its own context, so both ADK factories return a model subclass that + bridges each model call back to the flow, where ``after_model_callback`` runs + (#1207). The callback tests below pin that for both factories. The bridge + closes before a complete response, so a task started on one (as ADK starts + tool calls) records in its own context (hazard #2). Framework-free at module level (the base-only job); each test skips unless its framework is installed. CI runs this file in the jobs that install them. @@ -28,6 +34,7 @@ from __future__ import annotations +import asyncio import json from pathlib import Path from typing import Any @@ -252,3 +259,112 @@ def after_model(callback_context: Any, llm_response: Any) -> None: # Documented gap: ADK's Runner runs the agent in a task of its own. assert donkey.last_call.status is LastCallStatus.UNOBSERVED assert in_callback == [LastCallStatus.OBSERVED] + + +async def test_adk_model_runner_is_unobserved_but_after_model_callback_sees_it() -> None: + pytest.importorskip("google.adk") + pytest.importorskip("litellm") + from google.adk.agents import LlmAgent + from google.adk.runners import InMemoryRunner + + donkey = _donkey() + in_callback: list[str | None] = [] + + def after_model(callback_context: Any, llm_response: Any) -> None: + in_callback.append(donkey.last_call.request_id) + + async with donkey: + agent = LlmAgent( + name="a", + model=donkey.adk.model("gpt-4o"), + after_model_callback=after_model, + ) + runner = InMemoryRunner(agent=agent, app_name="app") + session = await runner.session_service.create_session(app_name="app", user_id="u") + async for _ in runner.run_async( + user_id="u", session_id=session.id, new_message=_gemini_message() + ): + pass + # Documented gap: ADK's Runner runs the agent in a task of its own. + assert donkey.last_call.status is LastCallStatus.UNOBSERVED + assert in_callback == ["rid-answer"] + + +def _gemini_stream(request: httpx.Request) -> httpx.Response: + return httpx.Response( + 200, + content=(GEMINI_FIXTURES / "responses.stream.body.txt").read_bytes(), + headers=parse_headers((GEMINI_FIXTURES / "responses.stream.headers.txt").read_text()), + ) + + +async def test_adk_streamed_run_after_model_callback_sees_every_response() -> None: + """The bridge stays open across partial responses and its record survives + the close, so each callback, the final complete one included, reads it.""" + pytest.importorskip("google.adk") + from google.adk.agents import LlmAgent + from google.adk.agents.run_config import RunConfig, StreamingMode + from google.adk.runners import InMemoryRunner + + donkey = _donkey(_gemini_stream) + in_callback: list[tuple[bool, LastCallStatus]] = [] + + def after_model(callback_context: Any, llm_response: Any) -> None: + in_callback.append((bool(llm_response.partial), donkey.last_call.status)) + + async with donkey: + agent = LlmAgent( + name="a", + model=donkey.adk.gemini("gemini-2.5-flash"), + after_model_callback=after_model, + ) + runner = InMemoryRunner(agent=agent, app_name="app") + session = await runner.session_service.create_session(app_name="app", user_id="u") + async for _ in runner.run_async( + user_id="u", + session_id=session.id, + new_message=_gemini_message(), + run_config=RunConfig(streaming_mode=StreamingMode.SSE), + ): + pass + observed = LastCallStatus.OBSERVED + assert in_callback == [(True, observed)] * 3 + [(False, observed)] + + +async def test_adk_factories_return_the_adk_classes() -> None: + pytest.importorskip("google.adk") + pytest.importorskip("litellm") + from google.adk.models import Gemini + from google.adk.models.lite_llm import LiteLlm + + donkey = _donkey() + async with donkey: + assert isinstance(donkey.adk.gemini("gemini-2.5-flash"), Gemini) + assert isinstance(donkey.adk.model("gpt-4o"), LiteLlm) + + +async def test_adk_bridge_closes_before_a_complete_response() -> None: + """A task started while ADK handles a complete response, as ADK starts a tool + call, records in its own context and not through the model call's bridge.""" + pytest.importorskip("google.adk") + from google.adk.models.llm_request import LlmRequest + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path.endswith("/chat/completions"): + return _answer(request) + return _gemini(request) + + donkey = _donkey(handler) + + async def sibling() -> None: + await donkey.llm.client().chat.completions.create( + model="gpt-4o", messages=[{"role": "user", "content": "hi"}] + ) + + async with donkey: + request = LlmRequest(model="gemini-2.5-flash", contents=[_gemini_message()]) + model = donkey.adk.gemini("gemini-2.5-flash") + async for response in model.generate_content_async(request): + if not response.partial: + await asyncio.create_task(sibling()) + assert donkey.last_call.requested_model == "gemini-2.5-flash" diff --git a/website/content/frameworks/adk.mdx b/website/content/frameworks/adk.mdx index fb6018aa..591dc6b5 100644 --- a/website/content/frameworks/adk.mdx +++ b/website/content/frameworks/adk.mdx @@ -50,7 +50,9 @@ from donkey_kit.integrations.adk import model llm = model("gpt-4o") ``` -`llm` is a real `google.adk.models.lite_llm.LiteLlm` instance. The model string +`llm` is a real `google.adk.models.lite_llm.LiteLlm` instance (a subclass that +carries `donkey.last_call` to your callbacks; see +[Native Gemini](#native-gemini)). The model string is prefixed with `openai/` before it reaches LiteLLM (`openai/gpt-4o`), which is the prefix LiteLLM's OpenAI-compatible route expects — you don't add it yourself. @@ -190,8 +192,10 @@ llm = Gemini( ) ``` -`client_kwargs` replaces ADK's default HTTP options wholesale, so all five -`http_options` keys are passed together. `google-genai` requires an API key and +All five `http_options` keys are passed together, because ADK builds its client +from `client_kwargs`. The `base_url`, `api_version` and `headers` you set are +kept, and ADK adds its own `x-goog-api-client` and `user-agent` tracking values +to the headers. `google-genai` requires an API key and always sends it as `x-goog-api-key`; the gateway authenticates on the `client_id`/`client_secret` pair and ignores it. @@ -206,7 +210,10 @@ populated. `last_call` is contextvar-scoped, and ADK's `Runner` makes the model call in a task of its own, so the caller of `runner.run_async(...)` reads `UNOBSERVED`. -Read it in an `after_model_callback`, which runs in the same task as the call: +Read it in an `after_model_callback`. Both `gemini()` and `model()` return a +subclass of the ADK class that carries the record from the model call to the +callback, including on `google-adk` 2.13 and later, which runs the model call in +a copy of the agent's context: ```python from google.adk.agents import LlmAgent @@ -252,7 +259,10 @@ run in `asyncio.run(...)` can build the `Donkey` (or call the module-level correlation ID bound by `donkey.run()` reaches every request. - **`donkey.last_call` is set in the context that made the call.** ADK's `Runner` calls the model in a task of its own, so read it in an - `after_model_callback` (see [Native Gemini](#native-gemini)). A cold read on + `after_model_callback` (see [Native Gemini](#native-gemini)). The factories' + models carry the record to that callback. A `LiteLlm` or `Gemini` you build + from `connection_kwargs()` or `gemini_connection_kwargs()` does not, so on + `google-adk` 2.13 and later its callback reads `UNOBSERVED`. A cold read on a `Donkey` that resolved only ADK reports `UNOBSERVED`, and `OBSERVED` after a call, for `model()` as well as `gemini()`. - **Refusals on the `model()` path are typed inside `donkey.run()` and diff --git a/website/public/frameworks/adk.md b/website/public/frameworks/adk.md index 848e1289..d74f43fd 100644 --- a/website/public/frameworks/adk.md +++ b/website/public/frameworks/adk.md @@ -41,7 +41,9 @@ from donkey_kit.integrations.adk import model llm = model("gpt-4o") ``` -`llm` is a real `google.adk.models.lite_llm.LiteLlm` instance. The model string +`llm` is a real `google.adk.models.lite_llm.LiteLlm` instance (a subclass that +carries `donkey.last_call` to your callbacks; see +[Native Gemini](#native-gemini)). The model string is prefixed with `openai/` before it reaches LiteLLM (`openai/gpt-4o`), which is the prefix LiteLLM's OpenAI-compatible route expects — you don't add it yourself. @@ -192,8 +194,10 @@ llm = Gemini( ) ``` -`client_kwargs` replaces ADK's default HTTP options wholesale, so all five -`http_options` keys are passed together. `google-genai` requires an API key and +All five `http_options` keys are passed together, because ADK builds its client +from `client_kwargs`. The `base_url`, `api_version` and `headers` you set are +kept, and ADK adds its own `x-goog-api-client` and `user-agent` tracking values +to the headers. `google-genai` requires an API key and always sends it as `x-goog-api-key`; the gateway authenticates on the `client_id`/`client_secret` pair and ignores it. @@ -208,7 +212,10 @@ populated. `last_call` is contextvar-scoped, and ADK's `Runner` makes the model call in a task of its own, so the caller of `runner.run_async(...)` reads `UNOBSERVED`. -Read it in an `after_model_callback`, which runs in the same task as the call: +Read it in an `after_model_callback`. Both `gemini()` and `model()` return a +subclass of the ADK class that carries the record from the model call to the +callback, including on `google-adk` 2.13 and later, which runs the model call in +a copy of the agent's context: ```python from google.adk.agents import LlmAgent @@ -254,7 +261,10 @@ run in `asyncio.run(...)` can build the `Donkey` (or call the module-level correlation ID bound by `donkey.run()` reaches every request. - **`donkey.last_call` is set in the context that made the call.** ADK's `Runner` calls the model in a task of its own, so read it in an - `after_model_callback` (see [Native Gemini](#native-gemini)). A cold read on + `after_model_callback` (see [Native Gemini](#native-gemini)). The factories' + models carry the record to that callback. A `LiteLlm` or `Gemini` you build + from `connection_kwargs()` or `gemini_connection_kwargs()` does not, so on + `google-adk` 2.13 and later its callback reads `UNOBSERVED`. A cold read on a `Donkey` that resolved only ADK reports `UNOBSERVED`, and `OBSERVED` after a call, for `model()` as well as `gemini()`. - **Refusals on the `model()` path are typed inside `donkey.run()` and diff --git a/website/public/llms-full.txt b/website/public/llms-full.txt index 339849a6..4c0e3c15 100644 --- a/website/public/llms-full.txt +++ b/website/public/llms-full.txt @@ -1412,7 +1412,9 @@ from donkey_kit.integrations.adk import model llm = model("gpt-4o") ``` -`llm` is a real `google.adk.models.lite_llm.LiteLlm` instance. The model string +`llm` is a real `google.adk.models.lite_llm.LiteLlm` instance (a subclass that +carries `donkey.last_call` to your callbacks; see +[Native Gemini](#native-gemini)). The model string is prefixed with `openai/` before it reaches LiteLLM (`openai/gpt-4o`), which is the prefix LiteLLM's OpenAI-compatible route expects — you don't add it yourself. @@ -1563,8 +1565,10 @@ llm = Gemini( ) ``` -`client_kwargs` replaces ADK's default HTTP options wholesale, so all five -`http_options` keys are passed together. `google-genai` requires an API key and +All five `http_options` keys are passed together, because ADK builds its client +from `client_kwargs`. The `base_url`, `api_version` and `headers` you set are +kept, and ADK adds its own `x-goog-api-client` and `user-agent` tracking values +to the headers. `google-genai` requires an API key and always sends it as `x-goog-api-key`; the gateway authenticates on the `client_id`/`client_secret` pair and ignores it. @@ -1579,7 +1583,10 @@ populated. `last_call` is contextvar-scoped, and ADK's `Runner` makes the model call in a task of its own, so the caller of `runner.run_async(...)` reads `UNOBSERVED`. -Read it in an `after_model_callback`, which runs in the same task as the call: +Read it in an `after_model_callback`. Both `gemini()` and `model()` return a +subclass of the ADK class that carries the record from the model call to the +callback, including on `google-adk` 2.13 and later, which runs the model call in +a copy of the agent's context: ```python from google.adk.agents import LlmAgent @@ -1625,7 +1632,10 @@ run in `asyncio.run(...)` can build the `Donkey` (or call the module-level correlation ID bound by `donkey.run()` reaches every request. - **`donkey.last_call` is set in the context that made the call.** ADK's `Runner` calls the model in a task of its own, so read it in an - `after_model_callback` (see [Native Gemini](#native-gemini)). A cold read on + `after_model_callback` (see [Native Gemini](#native-gemini)). The factories' + models carry the record to that callback. A `LiteLlm` or `Gemini` you build + from `connection_kwargs()` or `gemini_connection_kwargs()` does not, so on + `google-adk` 2.13 and later its callback reads `UNOBSERVED`. A cold read on a `Donkey` that resolved only ADK reports `UNOBSERVED`, and `OBSERVED` after a call, for `model()` as well as `gemini()`. - **Refusals on the `model()` path are typed inside `donkey.run()` and