Skip to content

Commit 146a016

Browse files
authored
fix(vertexai): route labels to request envelope instead of GenerationConfig (#1865)
Closes #1118 `ChatVertexAI` hard-fails with `ValueError: Unknown field for GenerationConfig: labels` whenever `labels` is passed at call time (`.invoke(..., labels={...})` or `.bind(labels=...)`). `"labels"` was listed in `_allowed_params`, which filters the kwargs used to build the Gemini `GenerationConfig` — but `GenerationConfig` has no `labels` field (it belongs on the `GenerateContentRequest` envelope). A constructor-level `labels=` worked because `self.labels` was passed straight to `GenerateContentRequest`; only the per-call path leaked into `GenerationConfig`. ## What - Remove `"labels"` from `_allowed_params` so it can never reach `GenerationConfig`. - In `_prepare_request_gemini`, pop a call-time `labels` kwarg and route it (falling back to `self.labels`) onto `GenerateContentRequest` across all return branches. This also fixes the cached-content branches, which previously dropped labels entirely. - Add a unit test covering init / per-call-override / per-call-only / none. - Drop the now-passing `xfail` markers on the two labels integration tests.
1 parent bab2961 commit 146a016

3 files changed

Lines changed: 108 additions & 7 deletions

File tree

libs/vertexai/langchain_google_vertexai/chat_models.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,6 @@
167167
"seed",
168168
"response_logprobs",
169169
"logprobs",
170-
"labels",
171170
"audio_timestamp",
172171
"response_modalities",
173172
"thinking_budget",
@@ -180,7 +179,6 @@
180179
"request",
181180
"timeout",
182181
"metadata",
183-
"labels",
184182
# Allow controlling GAPIC client retries from callers.
185183
"retry",
186184
]
@@ -1899,7 +1897,7 @@ class Joke(BaseModel):
18991897
"""
19001898

19011899
labels: dict[str, str] | None = None
1902-
"""Optional tag llm calls with metadata to help in tracebility and biling."""
1900+
"""Optional tag llm calls with metadata to help in traceability and billing."""
19031901

19041902
perform_literal_eval_on_string_raw_content: bool = False
19051903
"""Whether to perform literal eval on string raw content."""
@@ -2242,6 +2240,14 @@ def _prepare_request_gemini(
22422240
formatted_safety_settings = self._safety_settings_gemini(safety_settings)
22432241
logprobs = logprobs if logprobs is not None else self.logprobs
22442242
logprobs = logprobs if isinstance(logprobs, (int, bool)) else False
2243+
# `labels` belongs on the request envelope (`GenerateContentRequest`), not on
2244+
# `GenerationConfig` (which rejects unknown fields) nor on the GAPIC
2245+
# `generate_content` call (which has no `labels` parameter). It is therefore
2246+
# excluded from both `_allowed_params` and `_allowed_params_prediction_service`
2247+
# so it cannot leak into either path. Pop any per-call value here and route it
2248+
# to the envelope below, falling back to the instance value.
2249+
request_labels = kwargs.pop("labels", None)
2250+
request_labels = request_labels if request_labels is not None else self.labels
22452251
generation_config = self._generation_config_gemini(
22462252
stream=stream, stop=stop, logprobs=logprobs, **kwargs
22472253
)
@@ -2334,6 +2340,7 @@ def _content_to_v1(contents: list[Content]) -> list[v1Content]:
23342340
safety_settings=v1_safety_settings,
23352341
generation_config=generation_config,
23362342
cached_content=full_cache_name,
2343+
labels=request_labels,
23372344
)
23382345

23392346
return GenerateContentRequest(
@@ -2342,6 +2349,7 @@ def _content_to_v1(contents: list[Content]) -> list[v1Content]:
23422349
safety_settings=formatted_safety_settings,
23432350
generation_config=generation_config,
23442351
cached_content=full_cache_name,
2352+
labels=request_labels,
23452353
)
23462354

23472355
if self.endpoint_version == "v1":
@@ -2353,7 +2361,7 @@ def _content_to_v1(contents: list[Content]) -> list[v1Content]:
23532361
safety_settings=v1_safety_settings,
23542362
generation_config=generation_config,
23552363
model=self.full_model_name,
2356-
labels=self.labels,
2364+
labels=request_labels,
23572365
)
23582366

23592367
return GenerateContentRequest(
@@ -2364,7 +2372,7 @@ def _content_to_v1(contents: list[Content]) -> list[v1Content]:
23642372
safety_settings=formatted_safety_settings,
23652373
generation_config=generation_config,
23662374
model=self.full_model_name,
2367-
labels=self.labels,
2375+
labels=request_labels,
23682376
)
23692377

23702378
def _request_from_cached_content(

libs/vertexai/tests/integration_tests/test_chat_models.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,7 +1414,6 @@ async def test_astream_events_langgraph_example() -> None:
14141414
assert output.additional_kwargs["function_call"]["name"] == "multiply"
14151415

14161416

1417-
@pytest.mark.xfail(reason="can't add labels to the gemini content")
14181417
@pytest.mark.release
14191418
def test_label_metadata() -> None:
14201419
llm = ChatVertexAI(
@@ -1427,7 +1426,6 @@ def test_label_metadata() -> None:
14271426
llm.invoke("hey! how are you")
14281427

14291428

1430-
@pytest.mark.xfail(reason="can't add labels to the gemini content using invoke method")
14311429
@pytest.mark.release
14321430
def test_label_metadata_invoke_method() -> None:
14331431
llm = ChatVertexAI(model=_DEFAULT_MODEL_NAME)

libs/vertexai/tests/unit_tests/test_chat_models.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1932,6 +1932,101 @@ def test_thinking_configuration() -> None:
19321932
assert request.generation_config.thinking_config.include_thoughts is True
19331933

19341934

1935+
def test_labels_on_request_envelope() -> None:
1936+
"""`labels` belongs on the request envelope, not on `GenerationConfig`.
1937+
1938+
Regression test: passing `labels` as an invocation-time keyword used to leak into
1939+
`GenerationConfig`, which rejects unknown fields and raised
1940+
``ValueError: Unknown field for GenerationConfig: labels``.
1941+
"""
1942+
input_message = HumanMessage("Query.")
1943+
1944+
# Init param: labels land on the request envelope.
1945+
llm = ChatVertexAI(
1946+
model=_DEFAULT_MODEL_NAME,
1947+
project="test-project",
1948+
labels={"team": "qa"},
1949+
)
1950+
request = llm._prepare_request_gemini([input_message])
1951+
assert dict(request.labels) == {"team": "qa"}
1952+
1953+
# Invocation param must not raise (the regression) and overrides the init value.
1954+
request = llm._prepare_request_gemini([input_message], labels={"env": "prod"})
1955+
assert dict(request.labels) == {"env": "prod"}
1956+
1957+
# Invocation param with no init value.
1958+
llm = ChatVertexAI(model=_DEFAULT_MODEL_NAME, project="test-project")
1959+
request = llm._prepare_request_gemini([input_message], labels={"only": "per-call"})
1960+
assert dict(request.labels) == {"only": "per-call"}
1961+
1962+
# No labels anywhere.
1963+
request = llm._prepare_request_gemini([input_message])
1964+
assert dict(request.labels) == {}
1965+
1966+
1967+
def test_labels_on_request_envelope_cached_content() -> None:
1968+
"""`labels` reach the envelope on the cached-content path.
1969+
1970+
Previously the cached-content branches omitted `labels` entirely, silently
1971+
dropping them; init-time and per-call values must now propagate.
1972+
"""
1973+
input_message = HumanMessage("Query.")
1974+
1975+
llm = ChatVertexAI(
1976+
model=_DEFAULT_MODEL_NAME,
1977+
project="test-project",
1978+
cached_content="my-cache",
1979+
labels={"team": "qa"},
1980+
)
1981+
request = llm._prepare_request_gemini([input_message])
1982+
assert dict(request.labels) == {"team": "qa"}
1983+
1984+
request = llm._prepare_request_gemini([input_message], labels={"env": "prod"})
1985+
assert dict(request.labels) == {"env": "prod"}
1986+
1987+
1988+
def test_labels_on_request_envelope_v1_endpoint() -> None:
1989+
"""`labels` reach the envelope on the ``v1`` endpoint path."""
1990+
input_message = HumanMessage("Query.")
1991+
1992+
llm = ChatVertexAI(
1993+
model=_DEFAULT_MODEL_NAME,
1994+
project="test-project",
1995+
endpoint_version="v1",
1996+
labels={"team": "qa"},
1997+
)
1998+
request = llm._prepare_request_gemini([input_message])
1999+
assert dict(request.labels) == {"team": "qa"}
2000+
2001+
request = llm._prepare_request_gemini([input_message], labels={"env": "prod"})
2002+
assert dict(request.labels) == {"env": "prod"}
2003+
2004+
2005+
def test_labels_via_public_invoke() -> None:
2006+
"""End-to-end: ``invoke(..., labels=...)`` routes labels to the envelope only.
2007+
2008+
Guards the user-facing path. ``labels`` must land on ``request.labels`` and must
2009+
NOT leak as a keyword to the GAPIC ``generate_content`` call, which has no
2010+
``labels`` parameter and would raise ``TypeError`` in production.
2011+
"""
2012+
with patch(
2013+
"langchain_google_vertexai._client_utils.v1beta1PredictionServiceClient"
2014+
) as mc:
2015+
response = GenerateContentResponse(
2016+
candidates=[Candidate(content=Content(parts=[Part(text="Hi")]))]
2017+
)
2018+
mock_generate_content = MagicMock(return_value=response)
2019+
mc.return_value.generate_content = mock_generate_content
2020+
2021+
llm = ChatVertexAI(model=_DEFAULT_MODEL_NAME, project="test-project")
2022+
llm.invoke([HumanMessage("Query.")], labels={"env": "prod"})
2023+
2024+
mock_generate_content.assert_called_once()
2025+
call_kwargs = mock_generate_content.call_args.kwargs
2026+
assert dict(call_kwargs["request"].labels) == {"env": "prod"}
2027+
assert "labels" not in call_kwargs
2028+
2029+
19352030
def test_thought_signature() -> None:
19362031
"""Test that thought signatures are correctly parsed and included in requests."""
19372032
llm = ChatVertexAI(

0 commit comments

Comments
 (0)