Skip to content

Commit e85a7b2

Browse files
google-genai-botcopybara-github
authored andcommitted
feat: support mTLS and GOOGLE_API_USE_MTLS_ENDPOINT for GDA client
PiperOrigin-RevId: 941478713
1 parent 9865e2b commit e85a7b2

6 files changed

Lines changed: 447 additions & 210 deletions

File tree

src/google/adk/integrations/bigquery/data_insights_tool.py

Lines changed: 24 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -13,17 +13,13 @@
1313
# limitations under the License.
1414
from __future__ import annotations
1515

16-
import json
1716
from typing import Any
1817
from typing import Dict
1918
from typing import List
2019

2120
from google.adk.tools import _gda_stream_util
2221
from google.auth.credentials import Credentials
23-
from google.cloud import bigquery
24-
import requests
2522

26-
from . import client
2723
from .config import BigQueryToolConfig
2824

2925
_GDA_CLIENT_ID = "GOOGLE_ADK"
@@ -117,48 +113,39 @@ def ask_data_insights(
117113
"""
118114
try:
119115
location = "global"
120-
if not credentials.token:
121-
error_message = (
122-
"Error: The provided credentials object does not have a valid access"
123-
" token.\n\nThis is often because the credentials need to be"
124-
" refreshed or require specific API scopes. Please ensure the"
125-
" credentials are prepared correctly before calling this"
126-
" function.\n\nThere may be other underlying causes as well."
127-
)
128-
return {
129-
"status": "ERROR",
130-
"error_details": "ask_data_insights requires a valid access token.",
116+
session, endpoint = _gda_stream_util.get_gda_session(credentials)
117+
with session:
118+
headers = {
119+
"Content-Type": "application/json",
120+
"X-Goog-API-Client": _GDA_CLIENT_ID,
131121
}
132-
headers = {
133-
"Authorization": f"Bearer {credentials.token}",
134-
"Content-Type": "application/json",
135-
"X-Goog-API-Client": _GDA_CLIENT_ID,
136-
}
137-
ca_url = f"https://geminidataanalytics.googleapis.com/v1beta/projects/{project_id}/locations/{location}:chat"
122+
ca_url = (
123+
f"{endpoint}/v1beta/projects/{project_id}/locations/{location}:chat"
124+
)
138125

139-
instructions = """**INSTRUCTIONS - FOLLOW THESE RULES:**
126+
instructions = """**INSTRUCTIONS - FOLLOW THESE RULES:**
140127
1. **CONTENT:** Your answer should present the supporting data and then provide a conclusion based on that data, including relevant details and observations where possible.
141128
2. **ANALYSIS DEPTH:** Your analysis must go beyond surface-level observations. Crucially, you must prioritize metrics that measure impact or outcomes over metrics that simply measure volume or raw counts. For open-ended questions, explore the topic from multiple perspectives to provide a holistic view.
142129
3. **OUTPUT FORMAT:** Your entire response MUST be in plain text format ONLY.
143130
4. **NO CHARTS:** You are STRICTLY FORBIDDEN from generating any charts, graphs, images, or any other form of visualization.
144131
"""
145132

146-
ca_payload = {
147-
"project": f"projects/{project_id}",
148-
"messages": [{"userMessage": {"text": user_query_with_context}}],
149-
"inlineContext": {
150-
"datasourceReferences": {
151-
"bq": {"tableReferences": table_references}
152-
},
153-
"systemInstruction": instructions,
154-
"options": {"chart": {"image": {"noImage": {}}}},
155-
},
156-
"clientIdEnum": _GDA_CLIENT_ID,
157-
}
133+
ca_payload = {
134+
"project": f"projects/{project_id}",
135+
"messages": [{"userMessage": {"text": user_query_with_context}}],
136+
"inlineContext": {
137+
"datasourceReferences": {
138+
"bq": {"tableReferences": table_references}
139+
},
140+
"systemInstruction": instructions,
141+
"options": {"chart": {"image": {"noImage": {}}}},
142+
},
143+
"clientIdEnum": _GDA_CLIENT_ID,
144+
}
158145

159-
resp = _gda_stream_util.get_stream(
160-
ca_url, ca_payload, headers, settings.max_query_result_rows
161-
)
146+
resp = _gda_stream_util.get_stream(
147+
session, ca_url, ca_payload, headers, settings.max_query_result_rows
148+
)
162149
except Exception as ex: # pylint: disable=broad-except
163150
return {
164151
"status": "ERROR",

src/google/adk/tools/_gda_stream_util.py

Lines changed: 100 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -16,69 +16,116 @@
1616
import json
1717
from typing import Any
1818

19+
from google.auth.transport import mtls
20+
from google.auth.transport import requests as auth_requests
1921
import requests
2022

23+
from google import auth
24+
25+
from ..utils import _mtls_utils
26+
27+
_GDA_DEFAULT_TEMPLATE = "https://geminidataanalytics.googleapis.com"
28+
_GDA_MTLS_TEMPLATE = "https://geminidataanalytics.mtls.googleapis.com"
29+
30+
31+
def get_gda_endpoint() -> str:
32+
"""Returns the GDA API endpoint based on mTLS configuration."""
33+
return _mtls_utils.get_api_endpoint(
34+
location="",
35+
default_template=_GDA_DEFAULT_TEMPLATE,
36+
mtls_template=_GDA_MTLS_TEMPLATE,
37+
)
38+
39+
40+
def get_gda_session(
41+
credentials: auth.credentials.Credentials,
42+
) -> tuple[requests.Session, str]:
43+
"""Creates an AuthorizedSession and returns it with the correct endpoint.
44+
45+
Args:
46+
credentials: The credentials to use for the request.
47+
48+
Returns:
49+
A tuple containing the authorized requests Session and the GDA endpoint.
50+
51+
Raises:
52+
ValueError: If the mTLS endpoint is selected but the client certificate
53+
is disabled.
54+
"""
55+
session = auth_requests.AuthorizedSession(credentials=credentials) # type: ignore[no-untyped-call]
56+
endpoint = get_gda_endpoint()
57+
58+
if endpoint == _GDA_MTLS_TEMPLATE:
59+
if not mtls.has_default_client_cert_source(): # type: ignore[no-untyped-call]
60+
raise ValueError(
61+
"mTLS endpoint is selected, but client certificate is not"
62+
" provisioned."
63+
)
64+
session.configure_mtls_channel() # type: ignore[no-untyped-call]
65+
66+
return session, endpoint
67+
2168

2269
def get_stream(
70+
session: requests.Session,
2371
url: str,
2472
ca_payload: dict[str, Any],
2573
headers: dict[str, str],
2674
max_query_result_rows: int,
2775
) -> list[dict[str, Any]]:
2876
"""Sends a JSON request to a streaming API and returns a list of messages."""
29-
with requests.Session() as s:
30-
accumulator = ""
31-
messages = []
32-
data_msg_idx = -1
33-
34-
with s.post(url, json=ca_payload, headers=headers, stream=True) as resp:
35-
resp.raise_for_status()
36-
for line in resp.iter_lines():
37-
if not line:
38-
continue
39-
40-
decoded_line = line.decode("utf-8")
41-
42-
if decoded_line == "[{":
43-
accumulator = "{"
44-
elif decoded_line == "}]":
45-
accumulator += "}"
46-
elif decoded_line == ",":
47-
continue
48-
else:
49-
accumulator += decoded_line
50-
51-
try:
52-
data_json = json.loads(accumulator)
53-
except ValueError:
54-
continue
55-
56-
accumulator = ""
57-
58-
if not isinstance(data_json, dict):
59-
messages.append(data_json)
60-
continue
61-
62-
processed_msg = None
63-
data_result = _extract_data_result(data_json)
64-
if data_result is not None:
65-
processed_msg = _format_data_retrieved(
66-
data_result, max_query_result_rows
67-
)
68-
if data_msg_idx >= 0:
69-
messages[data_msg_idx] = {
70-
"Data Retrieved": "Intermediate result omitted"
71-
}
72-
data_msg_idx = len(messages)
73-
elif isinstance(data_json.get("systemMessage"), dict):
74-
processed_msg = data_json["systemMessage"]
75-
else:
76-
processed_msg = data_json
77-
78-
if processed_msg is not None:
79-
messages.append(processed_msg)
80-
81-
return messages
77+
accumulator = ""
78+
messages = []
79+
data_msg_idx = -1
80+
81+
with session.post(url, json=ca_payload, headers=headers, stream=True) as resp:
82+
resp.raise_for_status()
83+
for line in resp.iter_lines():
84+
if not line:
85+
continue
86+
87+
decoded_line = line.decode("utf-8")
88+
89+
if decoded_line == "[{":
90+
accumulator = "{"
91+
elif decoded_line == "}]":
92+
accumulator += "}"
93+
elif decoded_line == ",":
94+
continue
95+
else:
96+
accumulator += decoded_line
97+
98+
try:
99+
data_json = json.loads(accumulator)
100+
except ValueError:
101+
continue
102+
103+
accumulator = ""
104+
105+
if not isinstance(data_json, dict):
106+
messages.append(data_json)
107+
continue
108+
109+
processed_msg = None
110+
data_result = _extract_data_result(data_json)
111+
if data_result is not None:
112+
processed_msg = _format_data_retrieved(
113+
data_result, max_query_result_rows
114+
)
115+
if data_msg_idx >= 0:
116+
messages[data_msg_idx] = {
117+
"Data Retrieved": "Intermediate result omitted"
118+
}
119+
data_msg_idx = len(messages)
120+
elif isinstance(data_json.get("systemMessage"), dict):
121+
processed_msg = data_json["systemMessage"]
122+
else:
123+
processed_msg = data_json
124+
125+
if processed_msg is not None:
126+
messages.append(processed_msg)
127+
128+
return messages
82129

83130

84131
def _extract_data_result(msg: dict[str, Any]) -> dict[str, Any] | None:

0 commit comments

Comments
 (0)