From fda103e8b54e4776237428e54c8150c0368128c6 Mon Sep 17 00:00:00 2001 From: Sami Jawhar Date: Fri, 30 Oct 2026 18:12:28 +0000 Subject: [PATCH] fix(interactions): send the caller's continuation_token on create Google's Interactions spec carries continuation_token as an input of CreateModelInteractionParams and CreateAgentInteractionParams (https://ai.google.dev/static/api/interactions.openapi.json, read 2026-10-30), and google-genai 2.34.0, published 2026-10-29, puts it at the top level of the create body when it resumes a foreground model interaction, which it does unless the caller passes automatic_continuation=False. Create dropped the field before the request left, so a caller who points Google's own client at this proxy, or who sends the token itself, had it removed and Google received a create with no token The field now goes out when the caller sets it, as a keyword-only parameter so no existing positional call changes, on the Gemini and Vertex AI paths, and a request that leaves it out sends the same body as before. The generated types follow the vendored spec snapshot, which #49266 refreshes, so they are left alone here #46240 withdrew this forwarding on 2026-10-13 in 62b91f25cadc, before it merged, because the API answered 400 "Unknown parameter 'continuation_token'." at the time. The snapshot vendored here on 2026-10-23 in 08f04fbc970f still has the field on two stream-event schemas only, where the live document read 2026-10-30 has it on the two create schemas as well. The API still refuses it on the two models reachable for testing, with "continuation_token is not supported for gemini-omni-1.1-flash-preview", naming the field per model rather than calling it unknown, so which models take it is Google's answer to give rather than something to decide by stripping the field Omp-Session: 01a14c3d-9d7f-7079-97aa-f810a884c050 --- litellm/interactions/main.py | 7 ++ litellm/interactions/utils.py | 1 + .../gemini/interactions/transformation.py | 2 + .../test_gemini_interactions_wire.py | 2 + tests/unit/interactions/test_main.py | 65 ++++++++++++++++--- 5 files changed, 68 insertions(+), 9 deletions(-) diff --git a/litellm/interactions/main.py b/litellm/interactions/main.py index acd43c9b830..0c13335d284 100644 --- a/litellm/interactions/main.py +++ b/litellm/interactions/main.py @@ -103,6 +103,7 @@ async def acreate( custom_llm_provider: str | None = None, *, agent_config: Mapping[str, object] | None = None, + continuation_token: str | None = None, **kwargs, ) -> InteractionsAPIResponse | AsyncIterator[InteractionsAPIStreamingResponse]: """ @@ -129,6 +130,8 @@ async def acreate( response_format: JSON schema for response format response_mime_type: MIME type of the response previous_interaction_id: ID of previous interaction for continuation + continuation_token: Token from an interaction that stopped at the per-request output + cap, sent back to resume that decode extra_headers: Additional headers extra_body: Additional body parameters timeout: Request timeout @@ -164,6 +167,7 @@ async def acreate( response_format=response_format, response_mime_type=response_mime_type, previous_interaction_id=previous_interaction_id, + continuation_token=continuation_token, extra_headers=extra_headers, extra_body=extra_body, timeout=timeout, @@ -232,6 +236,7 @@ def create( custom_llm_provider: str | None = None, *, agent_config: Mapping[str, object] | None = None, + continuation_token: str | None = None, **kwargs, ) -> ( InteractionsAPIResponse @@ -262,6 +267,8 @@ def create( response_format: JSON schema for response format response_mime_type: MIME type of the response previous_interaction_id: ID of previous interaction for continuation + continuation_token: Token from an interaction that stopped at the per-request output + cap, sent back to resume that decode extra_headers: Additional headers extra_body: Additional body parameters timeout: Request timeout diff --git a/litellm/interactions/utils.py b/litellm/interactions/utils.py index 3895a85061d..1d7465a99c1 100644 --- a/litellm/interactions/utils.py +++ b/litellm/interactions/utils.py @@ -20,6 +20,7 @@ INTERACTIONS_API_OPTIONAL_PARAMS: Final = { "response_format", "response_mime_type", "previous_interaction_id", + "continuation_token", "agent_config", } diff --git a/litellm/llms/gemini/interactions/transformation.py b/litellm/llms/gemini/interactions/transformation.py index 1895a97af7d..e3486475f70 100644 --- a/litellm/llms/gemini/interactions/transformation.py +++ b/litellm/llms/gemini/interactions/transformation.py @@ -126,6 +126,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): "response_format", "response_mime_type", "previous_interaction_id", + "continuation_token", ] def validate_environment( @@ -206,6 +207,7 @@ class GoogleAIStudioInteractionsConfig(BaseInteractionsAPIConfig): "environment", "response_modalities", "previous_interaction_id", + "continuation_token", ] dropped_keys: Final = ( frozenset({"agent_config"}) diff --git a/tests/integration/providers/test_gemini_interactions_wire.py b/tests/integration/providers/test_gemini_interactions_wire.py index 58a7ccb31c3..fcba257a413 100644 --- a/tests/integration/providers/test_gemini_interactions_wire.py +++ b/tests/integration/providers/test_gemini_interactions_wire.py @@ -20,6 +20,7 @@ _MODEL_BODY: Final = { "response_mime_type": "application/json", "response_format": {"type": "object", "properties": {"plan": {"type": "string"}}}, "previous_interaction_id": "interaction-prev-123", + "continuation_token": "Y29udGludWUtZnJvbS1oZXJl", "store": True, "background": True, } @@ -120,6 +121,7 @@ def test_interaction_forwards_native_body_to_gemini(gateway: Gateway, prefix: st "store": True, "background": True, "previous_interaction_id": "interaction-prev-123", + "continuation_token": "Y29udGludWUtZnJvbS1oZXJl", "generation_config": {"temperature": 0.3}, "response_format": [ { diff --git a/tests/unit/interactions/test_main.py b/tests/unit/interactions/test_main.py index 5af5dd7d82a..617eaab4848 100644 --- a/tests/unit/interactions/test_main.py +++ b/tests/unit/interactions/test_main.py @@ -559,29 +559,76 @@ class TestInteractionsAcreateOffline: @pytest.mark.usefixtures("fake_provider_credentials") @pytest.mark.asyncio - async def test_acreate_gemini_keeps_continuation_token_out_of_the_request(self, respx_mock: MockRouter) -> None: + async def test_acreate_gemini_resumes_with_the_token_it_handed_the_caller(self, respx_mock: MockRouter) -> None: + """The token of a cut interaction comes back on the response and reaches Google on the resend. + + continuation_token is an input of CreateModelInteractionParams and an output of Interaction in + https://ai.google.dev/static/api/interactions.openapi.json, read 2026-10-30 + """ + cut: Final = { + "id": "interaction-cut", + "object": "interaction", + "model": "gemini-3.8-flash", + "status": "requires_continuation", + "steps": [{"type": "model_output", "content": [{"type": "text", "text": "Rivers"}]}], + "usage": {"input_tokens": 6, "output_tokens": 3}, + "continuation_token": "dG9rLTE=", + } + rest: Final = {**cut, "id": "interaction-rest", "status": "completed", "continuation_token": None} + route: Final = respx_mock.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock( + side_effect=[httpx.Response(200, json=cut), httpx.Response(200, json=rest)] + ) + first: Final = await interactions.acreate( + model="gemini/gemini-3.8-flash", + input="Prove it", + api_key="gemini-offline", + ) + assert first.status == "requires_continuation" + assert first.continuation_token == "dG9rLTE=" + assert "continuation_token" not in json.loads(route.calls[0].request.content) + + resumed: Final = await interactions.acreate( + model="gemini/gemini-3.8-flash", + input="Prove it", + continuation_token=first.continuation_token, + api_key="gemini-offline", + ) + assert json.loads(route.calls.last.request.content) == { + "model": "gemini-3.8-flash", + "input": "Prove it", + "continuation_token": "dG9rLTE=", + } + assert resumed.status == "completed" + + @pytest.mark.usefixtures("fake_provider_credentials") + @pytest.mark.asyncio + async def test_acreate_gemini_binds_the_fifteenth_positional_argument_to_extra_headers( + self, respx_mock: MockRouter + ) -> None: + """A caller passing model, agent, input and the eleven params after them positionally still + lands its headers in ``extra_headers``, so adding a create parameter cannot shift that slot.""" route: Final = respx_mock.post("https://generativelanguage.googleapis.com/v1beta/interactions").mock( return_value=httpx.Response( 200, json={ - "id": "interaction-offline", + "id": "interaction-positional", "object": "interaction", "model": "gemini-3.8-flash", "status": "completed", "steps": [{"type": "model_output", "content": [{"type": "text", "text": "Hi"}]}], - "usage": {"input_tokens": 6, "output_tokens": 3}, }, ) ) - response: Final = await interactions.acreate( - model="gemini/gemini-3.8-flash", - input="Prove it", - continuation_token="dG9rLTE=", + await interactions.acreate( + "gemini/gemini-3.8-flash", + None, + "Prove it", + *([None] * 11), + {"X-Trace-Id": "trace-9"}, api_key="gemini-offline", ) - # The Interactions API answered 400 "Unknown parameter 'continuation_token'." on 2026-10-13 + assert route.calls.last.request.headers["x-trace-id"] == "trace-9" assert json.loads(route.calls.last.request.content) == {"model": "gemini-3.8-flash", "input": "Prove it"} - assert response.status == "completed" @pytest.mark.usefixtures("fake_provider_credentials") @pytest.mark.asyncio