fix(vertex_ai): send Vertex Claude labels as X-Vertex-AI-Labels header instead of the request body (#48753)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
ryan-crabbe-berri 2026-10-28 08:41:50 -07:00 • committed by GitHub
parent cfc651dcd5
commit db5b940143
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 169 additions and 0 deletions

View file

@ -1,5 +1,7 @@
# What is this?
## Handler file for calling claude-3 on vertex ai
import base64
import json
from collections.abc import Mapping
from typing import TYPE_CHECKING, Final
@ -19,6 +21,19 @@ if TYPE_CHECKING:
from litellm.litellm_core_utils.tokenizer import Encoding as Tokenizer
_NON_DEFAULT_PARAMS: Final = TypeAdapter(Mapping[str, object])
_VERTEX_LABELS_HEADER: Final = "X-Vertex-AI-Labels"
_LABELS: Final = TypeAdapter(Mapping[object, object])
def _vertex_labels_header(labels: object, header_names: Mapping[str, object]) -> str | None:
if not isinstance(labels, dict) or any(name.lower() == _VERTEX_LABELS_HEADER.lower() for name in header_names):
return None
string_labels: Final = {
key: value
for key, value in _LABELS.validate_python(labels).items()
if isinstance(key, str) and isinstance(value, str)
}
return base64.b64encode(json.dumps(string_labels).encode()).decode() if string_labels else None
class VertexAIError(Exception):
@ -102,6 +117,12 @@ class VertexAIAnthropicConfig(AnthropicConfig):
litellm_params: dict,
headers: dict,
) -> dict:
params: Final = _NON_DEFAULT_PARAMS.validate_python(optional_params)
labels_header: Final = _vertex_labels_header(params.get("labels"), _NON_DEFAULT_PARAMS.validate_python(headers))
if "labels" in params:
del optional_params["labels"] # rebind-ok: labels travel in the X-Vertex-AI-Labels header, not the body
if labels_header is not None:
headers[_VERTEX_LABELS_HEADER] = labels_header # rebind-ok: outbound headers are returned by mutation
data: Final = super().transform_request(
model=model,
messages=messages,

View file

@ -0,0 +1,120 @@
import base64
import json
from typing import Final
import pytest
from integration._support.client import Gateway, Scenario
from integration._support.vertex import service_account_json
from integration._support.wire import Reply, Request, Wire, wire_server
from pydantic import JsonValue, TypeAdapter
_BACKEND: Final = "claude-sonnet-5-5"
_PROJECT: Final = "scripted-project"
_LOCATION: Final = "us-east5"
_MODEL_PATH: Final = f"/v1/projects/{_PROJECT}/locations/{_LOCATION}/publishers/anthropic/models/{_BACKEND}"
_TARGET: Final = f"{_MODEL_PATH}:rawPredict"
_STREAM_TARGET: Final = f"{_MODEL_PATH}:streamRawPredict?alt=sse"
_LABELS_HEADER: Final = "x-vertex-ai-labels"
_REPLY_TEXT: Final = "labels control"
_JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
_LABELS: Final[dict[str, JsonValue]] = {"team": "triage", "env": "prod"}
_REPLY: Final[dict[str, JsonValue]] = {
"id": "msg_vertex_labels",
"type": "message",
"role": "assistant",
"model": _BACKEND,
"content": [{"type": "text", "text": _REPLY_TEXT}],
"stop_reason": "end_turn",
"stop_sequence": None,
"usage": {"input_tokens": 5, "output_tokens": 3},
}
_EVENTS: Final[tuple[dict[str, JsonValue], ...]] = (
{"type": "message_start", "message": {**_REPLY, "content": [], "stop_reason": None}},
{"type": "content_block_start", "index": 0, "content_block": {"type": "text", "text": ""}},
{"type": "content_block_delta", "index": 0, "delta": {"type": "text_delta", "text": _REPLY_TEXT}},
{"type": "content_block_stop", "index": 0},
{
"type": "message_delta",
"delta": {"stop_reason": "end_turn", "stop_sequence": None},
"usage": {"output_tokens": 3},
},
{"type": "message_stop"},
)
_SSE: Final = tuple(f"event: {event['type']}\ndata: {json.dumps(event)}\n\n".encode() for event in _EVENTS)
def _peer(request: Request) -> Reply:
if request.target == "/_oauth/token":
return Reply(
body=json.dumps({"access_token": "scripted-token", "token_type": "Bearer", "expires_in": 3600}).encode()
)
if request.target == _STREAM_TARGET:
return Reply(content_type="text/event-stream", chunks=_SSE)
if request.target == _TARGET:
return Reply(body=json.dumps(_REPLY).encode())
return Reply(status=404, body=json.dumps({"error": f"unscripted target {request.target}"}).encode())
def _deployment(scenario: Scenario, api_base: str) -> str:
return scenario.model(
model=f"vertex_ai/{_BACKEND}",
api_base=api_base,
api_key=None,
vertex_project=_PROJECT,
vertex_location=_LOCATION,
vertex_credentials=service_account_json(_PROJECT, api_base),
num_retries=0,
)
def _chat(gateway: Gateway, wire: Wire, scenario: Scenario, stream: bool, **extra: JsonValue) -> Request:
model: Final = _deployment(scenario, wire.url)
response: Final = gateway.request(
"POST",
"/v1/chat/completions",
{
"model": model,
"messages": [{"role": "user", "content": "Reply ok"}],
"max_tokens": 16,
"stream": stream,
**extra,
},
)
assert response.status_code == 200, response.text
assert _REPLY_TEXT in response.text, response.text
(sent,) = tuple(request for request in wire.drain() if request.target in (_TARGET, _STREAM_TARGET))
assert sent.target == (_STREAM_TARGET if stream else _TARGET), sent.target
return sent
def _decoded_labels(sent: Request) -> JsonValue:
return json.loads(base64.b64decode(sent.headers[_LABELS_HEADER]))
@pytest.mark.parametrize("stream", [False, True], ids=["non_stream", "stream"])
def test_regression_vertex_claude_labels_move_from_body_to_header(gateway: Gateway, stream: bool) -> None:
with gateway.scenario() as scenario, wire_server(_peer) as wire:
sent: Final = _chat(gateway, wire, scenario, stream, labels=_LABELS)
assert "labels" not in _JSON_OBJECT.validate_json(sent.body), sent.body
assert _decoded_labels(sent) == _LABELS, sent.headers
def test_guard_vertex_claude_without_labels_sends_no_labels_header(gateway: Gateway) -> None:
with gateway.scenario() as scenario, wire_server(_peer) as wire:
sent: Final = _chat(gateway, wire, scenario, False)
assert "labels" not in _JSON_OBJECT.validate_json(sent.body), sent.body
assert _LABELS_HEADER not in sent.headers, sent.headers
def test_regression_vertex_claude_caller_labels_header_wins_over_labels_param(gateway: Gateway) -> None:
own_header: Final = base64.b64encode(json.dumps({"team": "caller"}).encode()).decode()
with gateway.scenario() as scenario, wire_server(_peer) as wire:
sent: Final = _chat(
gateway, wire, scenario, False, labels=_LABELS, extra_headers={"X-Vertex-AI-Labels": own_header}
)
assert "labels" not in _JSON_OBJECT.validate_json(sent.body), sent.body
assert sent.headers[_LABELS_HEADER] == own_header, sent.headers

View file

@ -1,4 +1,5 @@
import base64
import copy
import json
from collections.abc import Mapping
@ -40,6 +41,33 @@ def test_vertex_ai_anthropic_thinking_param(model, expected_thinking):
assert "thinking" not in supported_openai_params
@pytest.mark.parametrize(
("labels", "headers", "expected_header"),
[
({"team": "triage", "env": "prod"}, {}, {"team": "triage", "env": "prod"}),
({"team": "triage", "cost": 3}, {}, {"team": "triage"}),
({"cost": 3}, {}, None),
("team=triage", {}, None),
({"team": "triage"}, {"x-vertex-ai-labels": "e30="}, {}),
],
ids=["string_labels", "non_string_values_dropped", "no_string_values", "not_a_mapping", "caller_header_wins"],
)
def test_vertex_anthropic_labels_move_from_body_to_header(
labels: object, headers: dict[str, str], expected_header: dict[str, str] | None
) -> None:
body: Final = VertexAIAnthropicConfig().transform_request(
model="claude-sonnet-5-5",
messages=[{"role": "user", "content": "Hi"}],
optional_params={"max_tokens": 16, "anthropic_version": "vertex-2023-10-16", "labels": labels},
litellm_params={},
headers=headers,
)
sent_labels: Final = next((value for name, value in headers.items() if name.lower() == "x-vertex-ai-labels"), None)
assert "labels" not in body
assert (None if sent_labels is None else json.loads(base64.b64decode(sent_labels))) == expected_header
@pytest.mark.parametrize(
("stream", "operation"),
[(False, "rawPredict"), (True, "streamRawPredict")],