[chore] Address TODO to migrate to VCRpy and remove bespoke RequestMocker code in Google GenAI SDK instrumentation (#3344)

* Remove bespoke request mocker. Replace with direct mocking of the underlying API.

* Refactor fake credentials to enable reuse.

* Add module to test end to end with a real client.

* Add redaction and minimal OTel mocking/testing in the e2e test.

* Fix wording of the documentation.

* Remove vcr migration from TODOs.

* Improve redaction and test naming.

* Minor tweaks in the code generation. Add casette files.

* Reformat with ruff.

* Fix lint and gzip issue.

* Reformat with ruff.

* Prevent fix for Python 3.9 from breaking tests in other versions.

* Record update in changelog.

* Don't double iterate when redacting by changing the value.

Co-authored-by: Aaron Abbott <aaronabbott@google.com>

---------

Co-authored-by: Aaron Abbott <aaronabbott@google.com>
This commit is contained in:
Michael Safyan
2025-03-19 12:58:15 -05:00
committed by GitHub
parent ad29af3996
commit e43e8c91cd
18 changed files with 1547 additions and 298 deletions

View File

@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## Unreleased
- Restructure tests to keep in line with repository conventions ([#3344](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/3344))
## Version 0.1b0 (2025-03-05)
- Add support for async and streaming.

View File

@ -13,7 +13,6 @@ Here are some TODO items required to achieve stability for this package:
- Additional cleanup/improvement tasks such as:
- Adoption of 'wrapt' instead of 'functools.wraps'
- Bolstering test coverage
- Migrate tests to use VCR.py
## Future

View File

@ -0,0 +1,20 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import google.auth.credentials
class FakeCredentials(google.auth.credentials.AnonymousCredentials):
def refresh(self, request):
pass

View File

@ -17,29 +17,22 @@ import unittest
import google.genai
from .auth import FakeCredentials
from .instrumentation_context import InstrumentationContext
from .otel_mocker import OTelMocker
from .requests_mocker import RequestsMocker
class _FakeCredentials(google.auth.credentials.AnonymousCredentials):
def refresh(self, request):
pass
class TestCase(unittest.TestCase):
def setUp(self):
self._otel = OTelMocker()
self._otel.install()
self._requests = RequestsMocker()
self._requests.install()
self._instrumentation_context = None
self._api_key = "test-api-key"
self._project = "test-project"
self._location = "test-location"
self._client = None
self._uses_vertex = False
self._credentials = _FakeCredentials()
self._credentials = FakeCredentials()
def _lazy_init(self):
self._instrumentation_context = InstrumentationContext()
@ -51,10 +44,6 @@ class TestCase(unittest.TestCase):
self._client = self._create_client()
return self._client
@property
def requests(self):
return self._requests
@property
def otel(self):
return self._otel
@ -62,6 +51,15 @@ class TestCase(unittest.TestCase):
def set_use_vertex(self, use_vertex):
self._uses_vertex = use_vertex
def reset_client(self):
self._client = None
def reset_instrumentation(self):
if self._instrumentation_context is None:
return
self._instrumentation_context.uninstall()
self._instrumentation_context = None
def _create_client(self):
self._lazy_init()
if self._uses_vertex:
@ -72,10 +70,9 @@ class TestCase(unittest.TestCase):
location=self._location,
credentials=self._credentials,
)
return google.genai.Client(api_key=self._api_key)
return google.genai.Client(vertexai=False, api_key=self._api_key)
def tearDown(self):
if self._instrumentation_context is not None:
self._instrumentation_context.uninstall()
self._requests.uninstall()
self._otel.uninstall()

View File

@ -1,238 +0,0 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# This file defines a "RequestMocker" that facilities mocking the "requests"
# API. There are a few reasons that we use this approach to testing:
#
# 1. Security - although "vcrpy" provides a means of filtering data,
# it can be error-prone; use of this solution risks exposing API keys,
# auth tokens, etc. It can also inadvertently record fields that are
# visibility-restricted (such as fields that are returned and available
# when recording using privileged API keys where such fields would not
# ordinarily be returned to users with non-privileged API keys).
#
# 2. Reproducibility - although the tests may be reproducible once the
# recording is present, updating the recording often has external
# dependencies that may be difficult to reproduce.
#
# 3. Costs - there are both time costs and monetary costs to the external
# dependencies required for a record/replay solution.
#
# Because they APIs that need to be mocked are simple enough and well documented
# enough, it seems approachable to mock the requests library, instead.
import copy
import functools
import http.client
import io
import json
from typing import Optional
import requests
import requests.sessions
class RequestsCallArgs:
def __init__(
self,
session: requests.sessions.Session,
request: requests.PreparedRequest,
**kwargs,
):
self._session = session
self._request = request
self._kwargs = kwargs
@property
def session(self):
return self._session
@property
def request(self):
return self._request
@property
def kwargs(self):
return self._kwargs
class RequestsCall:
def __init__(self, args: RequestsCallArgs, response_generator):
self._args = args
self._response_generator = response_generator
@property
def args(self):
return self._args
@property
def response(self):
return self._response_generator(self._args)
def _return_error_status(
args: RequestsCallArgs, status_code: int, reason: Optional[str] = None
):
result = requests.Response()
result.url = args.request.url
result.status_code = status_code
result.reason = reason or http.client.responses.get(status_code)
result.request = args.request
return result
def _return_404(args: RequestsCallArgs):
return _return_error_status(args, 404, "Not Found")
def _to_response_generator(response):
if response is None:
raise ValueError("response must not be None")
if isinstance(response, int):
return lambda args: _return_error_status(args, response)
if isinstance(response, requests.Response):
def generate_response_from_response(args):
new_response = copy.deepcopy(response)
new_response.request = args.request
new_response.url = args.request.url
return new_response
return generate_response_from_response
if isinstance(response, dict):
def generate_response_from_dict(args):
result = requests.Response()
result.status_code = 200
result.headers["content-type"] = "application/json"
result.encoding = "utf-8"
result.raw = io.BytesIO(json.dumps(response).encode())
return result
return generate_response_from_dict
raise ValueError(f"Unsupported response type: {type(response)}")
def _to_stream_response_generator(response_generators):
if len(response_generators) == 1:
return response_generators[0]
def combined_generator(args):
first_response = response_generators[0](args)
if first_response.status_code != 200:
return first_response
result = requests.Response()
result.status_code = 200
result.headers["content-type"] = "application/json"
result.encoding = "utf-8"
result.headers["transfer-encoding"] = "chunked"
contents = []
for generator in response_generators:
response = generator(args)
if response.status_code != 200:
continue
response_json = response.json()
response_json_str = json.dumps(response_json)
contents.append(f"data: {response_json_str}")
contents_str = "\r\n".join(contents)
full_contents = f"{contents_str}\r\n\r\n"
result.raw = io.BytesIO(full_contents.encode())
return result
return combined_generator
class RequestsMocker:
def __init__(self):
self._original_send = requests.sessions.Session.send
self._calls = []
self._handlers = []
def install(self):
@functools.wraps(requests.sessions.Session.send)
def replacement_send(
s: requests.sessions.Session,
request: requests.PreparedRequest,
**kwargs,
):
return self._do_send(s, request, **kwargs)
requests.sessions.Session.send = replacement_send
def uninstall(self):
requests.sessions.Session.send = self._original_send
def reset(self):
self._calls = []
self._handlers = []
def add_response(self, response, if_matches=None):
self._handlers.append((if_matches, _to_response_generator(response)))
@property
def calls(self):
return self._calls
def _do_send(
self,
session: requests.sessions.Session,
request: requests.PreparedRequest,
**kwargs,
):
stream = kwargs.get("stream", False)
if not stream:
return self._do_send_non_streaming(session, request, **kwargs)
return self._do_send_streaming(session, request, **kwargs)
def _do_send_streaming(
self,
session: requests.sessions.Session,
request: requests.PreparedRequest,
**kwargs,
):
args = RequestsCallArgs(session, request, **kwargs)
response_generators = []
for matcher, response_generator in self._handlers:
if matcher is None:
response_generators.append(response_generator)
elif matcher(args):
response_generators.append(response_generator)
if not response_generators:
response_generators.append(_return_404)
response_generator = _to_stream_response_generator(response_generators)
call = RequestsCall(args, response_generator)
result = call.response
self._calls.append(call)
return result
def _do_send_non_streaming(
self,
session: requests.sessions.Session,
request: requests.PreparedRequest,
**kwargs,
):
args = RequestsCallArgs(session, request, **kwargs)
response_generator = self._lookup_response_generator(args)
call = RequestsCall(args, response_generator)
result = call.response
self._calls.append(call)
return result
def _lookup_response_generator(self, args: RequestsCallArgs):
for matcher, response_generator in self._handlers:
if matcher is None:
return response_generator
if matcher(args):
return response_generator
return _return_404

View File

@ -0,0 +1,163 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
import unittest
import unittest.mock
from google.genai.models import AsyncModels, Models
from ..common.base import TestCase as CommonTestCaseBase
from .util import convert_to_response, create_response
# Helper used in "_install_mocks" below.
def _wrap_output(mock_generate_content):
def _wrapped(*args, **kwargs):
return convert_to_response(mock_generate_content(*args, **kwargs))
return _wrapped
# Helper used in "_install_mocks" below.
def _wrap_output_stream(mock_generate_content_stream):
def _wrapped(*args, **kwargs):
for output in mock_generate_content_stream(*args, **kwargs):
yield convert_to_response(output)
return _wrapped
# Helper used in "_install_mocks" below.
def _async_wrapper(mock_generate_content):
async def _wrapped(*args, **kwargs):
return mock_generate_content(*args, **kwargs)
return _wrapped
# Helper used in "_install_mocks" below.
def _async_stream_wrapper(mock_generate_content_stream):
async def _wrapped(*args, **kwargs):
async def _internal_generator():
for result in mock_generate_content_stream(*args, **kwargs):
yield result
return _internal_generator()
return _wrapped
class TestCase(CommonTestCaseBase):
# The "setUp" function is defined by "unittest.TestCase" and thus
# this name must be used. Uncertain why pylint doesn't seem to
# recognize that this is a unit test class for which this is inherited.
def setUp(self): # pylint: disable=invalid-name
super().setUp()
if self.__class__ == TestCase:
raise unittest.SkipTest("Skipping testcase base.")
self._generate_content_mock = None
self._generate_content_stream_mock = None
self._original_generate_content = Models.generate_content
self._original_generate_content_stream = Models.generate_content_stream
self._original_async_generate_content = AsyncModels.generate_content
self._original_async_generate_content_stream = (
AsyncModels.generate_content_stream
)
self._responses = []
self._response_index = 0
@property
def mock_generate_content(self):
if self._generate_content_mock is None:
self._create_and_install_mocks()
return self._generate_content_mock
@property
def mock_generate_content_stream(self):
if self._generate_content_stream_mock is None:
self._create_and_install_mocks()
return self._generate_content_stream_mock
def configure_valid_response(self, **kwargs):
self._create_and_install_mocks()
response = create_response(**kwargs)
self._responses.append(response)
def _create_and_install_mocks(self):
if self._generate_content_mock is not None:
return
self.reset_client()
self.reset_instrumentation()
self._generate_content_mock = self._create_nonstream_mock()
self._generate_content_stream_mock = self._create_stream_mock()
self._install_mocks()
def _create_nonstream_mock(self):
mock = unittest.mock.MagicMock()
def _default_impl(*args, **kwargs):
if not self._responses:
return create_response(text="Some response")
index = self._response_index % len(self._responses)
result = self._responses[index]
self._response_index += 1
return result
mock.side_effect = _default_impl
return mock
def _create_stream_mock(self):
mock = unittest.mock.MagicMock()
def _default_impl(*args, **kwargs):
for response in self._responses:
yield response
mock.side_effect = _default_impl
return mock
def _install_mocks(self):
output_wrapped = _wrap_output(self._generate_content_mock)
output_wrapped_stream = _wrap_output_stream(
self._generate_content_stream_mock
)
Models.generate_content = output_wrapped
Models.generate_content_stream = output_wrapped_stream
AsyncModels.generate_content = _async_wrapper(output_wrapped)
AsyncModels.generate_content_stream = _async_stream_wrapper(
output_wrapped_stream
)
def tearDown(self):
super().tearDown()
if self._generate_content_mock is None:
assert Models.generate_content == self._original_generate_content
assert (
Models.generate_content_stream
== self._original_generate_content_stream
)
assert (
AsyncModels.generate_content
== self._original_async_generate_content
)
assert (
AsyncModels.generate_content_stream
== self._original_async_generate_content_stream
)
Models.generate_content = self._original_generate_content
Models.generate_content_stream = self._original_generate_content_stream
AsyncModels.generate_content = self._original_async_generate_content
AsyncModels.generate_content_stream = (
self._original_async_generate_content_stream
)

View File

@ -0,0 +1,94 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:generateContent
response:
body:
string: |-
{
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": "No more dark, inscrutable ways,\nTo trace a request through hazy days.\nOpen Telemetry, a beacon bright,\nIlluminates the path, both day and night.\n\nFrom metrics gathered, a clear display,\nOf latency's dance, and errors' sway.\nTraces unwind, a silken thread,\nShowing the journey, from start to head.\n\nLogs interweave, a richer hue,\nContextual clues, for me and you.\nNo vendor lock-in, a freedom's call,\nTo choose your tools, to stand up tall.\n\nExporters aplenty, a varied choice,\nTo send your data, amplify your voice.\nJaeger, Zipkin, Prometheus' might,\nAll integrate, a glorious sight.\n\nWith spans and attributes, a detailed scene,\nOf how your system works, both sleek and keen.\nPerformance bottlenecks, now laid bare,\nOpen Telemetry, beyond compare.\n\nSo embrace the light, let darkness flee,\nWith Open Telemetry, set your systems free.\nObserve, and learn, and optimize with grace,\nA brighter future, in this digital space.\n"
}
]
},
"finishReason": "STOP",
"avgLogprobs": -0.3303731600443522
}
],
"usageMetadata": {
"promptTokenCount": 8,
"candidatesTokenCount": 240,
"totalTokenCount": 248,
"promptTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 8
}
],
"candidatesTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 240
}
]
},
"modelVersion": "gemini-1.5-flash-002",
"createTime": "2025-03-07T22:19:18.083091Z",
"responseId": "5nDLZ5OJBdyY3NoPiZGx0Ag"
}
headers:
Content-Encoding:
- gzip
Content-Type:
- application/json; charset=UTF-8
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -0,0 +1,94 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:generateContent
response:
body:
string: |-
{
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": "No more dark logs, a cryptic, hidden trace,\nOf failing systems, lost in time and space.\nOpenTelemetry, a beacon shining bright,\nIlluminating paths, both dark and light.\n\nFrom microservices, a sprawling, tangled mesh,\nTo monolithic beasts, put to the test,\nIt gathers traces, spans, and metrics too,\nA holistic view, for me and you.\n\nWith signals clear, from every single node,\nPerformance bottlenecks, instantly bestowed.\nDistributed tracing, paints a vivid scene,\nWhere latency lurks, and slowdowns intervene.\n\nExporters rise, to send the data forth,\nTo dashboards grand, of proven, measured worth.\nPrometheus, Grafana, Jaeger, fluent streams,\nVisualizing insights, fulfilling data dreams.\n\nFrom Jaeger's diagrams, a branching, flowing art,\nTo Grafana's charts, that play a vital part,\nThe mysteries unravel, hidden deep inside,\nWhere errors slumber, and slow responses hide.\n\nSo hail OpenTelemetry, a gift to all who code,\nA brighter future, on a well-lit road.\nNo more guesswork, no more fruitless chase,\nJust clear observability, in time and space.\n"
}
]
},
"finishReason": "STOP",
"avgLogprobs": -0.45532724261283875
}
],
"usageMetadata": {
"promptTokenCount": 8,
"candidatesTokenCount": 256,
"totalTokenCount": 264,
"promptTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 8
}
],
"candidatesTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 256
}
]
},
"modelVersion": "gemini-1.5-flash-002",
"createTime": "2025-03-07T22:19:15.268428Z",
"responseId": "43DLZ4yxEM6F3NoPzaTkiQU"
}
headers:
Content-Encoding:
- gzip
Content-Type:
- application/json; charset=UTF-8
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -0,0 +1,94 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:generateContent
response:
body:
string: |-
{
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": "No more dark, mysterious traces,\nNo more guessing, in empty spaces.\nOpenTelemetry's light now shines,\nIlluminating all our designs.\n\nFrom microservices, small and fleet,\nTo monolithic beasts, hard to beat,\nIt weaves a net, both fine and strong,\nWhere metrics flow, where logs belong.\n\nTraces dance, a vibrant hue,\nShowing journeys, old and new.\nSpans unfold, a story told,\nOf requests handled, brave and bold.\n\nMetrics hum, a steady beat,\nLatency, errors, can't be beat.\nDistribution charts, a clear display,\nGuiding us along the way.\n\nLogs provide a detailed view,\nOf what happened, me and you.\nContext rich, with helpful clues,\nDebugging woes, it quickly subdues.\n\nWith exporters wise, a thoughtful choice,\nTo Prometheus, Jaeger, or Zipkin's voice,\nOur data flows, a precious stream,\nReal-time insights, a waking dream.\n\nSo hail to OpenTelemetry's might,\nBringing clarity to our darkest night.\nObservability's champion, bold and true,\nA brighter future, for me and you.\n"
}
]
},
"finishReason": "STOP",
"avgLogprobs": -0.4071464086238575
}
],
"usageMetadata": {
"promptTokenCount": 8,
"candidatesTokenCount": 253,
"totalTokenCount": 261,
"promptTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 8
}
],
"candidatesTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 253
}
]
},
"modelVersion": "gemini-1.5-flash-002",
"createTime": "2025-03-07T22:19:12.443989Z",
"responseId": "4HDLZ9WMG6SK698Pr5uZ2Qw"
}
headers:
Content-Encoding:
- gzip
Content-Type:
- application/json; charset=UTF-8
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -0,0 +1,94 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:generateContent
response:
body:
string: |-
{
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": "No more dark, mysterious traces,\nOf failing systems, hidden spaces.\nOpen Telemetry's light shines bright,\nGuiding us through the darkest night.\n\nFrom metrics gathered, finely spun,\nTo logs that tell of tasks undone,\nAnd traces linking every call,\nIt answers questions, standing tall.\n\nDistributed systems, complex and vast,\nTheir hidden flaws, no longer cast\nIn shadows deep, beyond our view,\nOpen Telemetry sees them through.\n\nWith spans and attributes, it weaves a tale,\nOf requests flowing, never frail.\nIt pinpoints bottlenecks, slow and grim,\nAnd helps us optimize, system trim.\n\nAcross languages, a common ground,\nWhere data's shared, and insights found.\nExporters whisper, collectors hum,\nA symphony of data, overcome.\n\nSo raise a glass, to this open source,\nA shining beacon, a powerful force.\nOpen Telemetry, a guiding star,\nRevealing secrets, near and far.\n"
}
]
},
"finishReason": "STOP",
"avgLogprobs": -0.3586180628193498
}
],
"usageMetadata": {
"promptTokenCount": 8,
"candidatesTokenCount": 211,
"totalTokenCount": 219,
"promptTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 8
}
],
"candidatesTokensDetails": [
{
"modality": "TEXT",
"tokenCount": 211
}
]
},
"modelVersion": "gemini-1.5-flash-002",
"createTime": "2025-03-07T22:19:09.936326Z",
"responseId": "3XDLZ4aTOZSpnvgPn-e0qQk"
}
headers:
Content-Encoding:
- gzip
Content-Type:
- application/json; charset=UTF-8
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -0,0 +1,97 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:streamGenerateContent?alt=sse
response:
body:
string: "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"No\"}]}}],\"usageMetadata\": {},\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:29.293930Z\",\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" longer dark, the tracing's light,\\nOpen Telemetry, shining\
\ bright\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\"\
: \"2025-03-07T22:19:29.293930Z\",\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \".\\nA beacon in the coding night,\\nRevealing paths, both\
\ dark\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"\
2025-03-07T22:19:29.293930Z\",\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" and bright.\\n\\nFrom microservice to sprawling beast,\\\
nIts watchful eye, a silent priest.\\nObserving calls, both small and vast,\\\
nPerformance\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\"\
: \"2025-03-07T22:19:29.293930Z\",\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" flaws, revealed at last.\\n\\nWith metrics gleaned and logs\
\ aligned,\\nA clearer picture, you will find.\\nOf latency, and errors dire,\\\
n\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:29.293930Z\"\
,\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"And bottlenecks\
\ that set afire.\\n\\nIt spans the clouds, a network wide,\\nWhere data streams,\
\ a surging tide.\\nCollecting traces, rich and deep,\\nWhile slumbering apps\
\ their secrets keep.\\n\\nJaeger, Zip\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:29.293930Z\",\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"kin, the tools it holds,\\nA tapestry of stories told.\\nOf\
\ requests flowing, swift and free,\\nOr tangled knots, for all to see.\\\
n\\nSo embrace the power, understand,\\nThe vital role, across the\"}]}}],\"\
modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:29.293930Z\"\
,\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" land.\\nOpen\
\ Telemetry, a guiding star,\\nTo navigate the digital afar.\\n\"}]},\"finishReason\"\
: \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 8,\"candidatesTokenCount\"\
: 212,\"totalTokenCount\": 220,\"promptTokensDetails\": [{\"modality\": \"\
TEXT\",\"tokenCount\": 8}],\"candidatesTokensDetails\": [{\"modality\": \"\
TEXT\",\"tokenCount\": 212}]},\"modelVersion\": \"gemini-1.5-flash-002\",\"\
createTime\": \"2025-03-07T22:19:29.293930Z\",\"responseId\": \"8XDLZ6r4Efa1-O4PwIHamQ4\"\
}\r\n\r\n"
headers:
Content-Disposition:
- attachment
Content-Type:
- text/event-stream
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -0,0 +1,102 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:streamGenerateContent?alt=sse
response:
body:
string: "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"The\"}]}}],\"usageMetadata\": {},\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:26.378633Z\",\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" black box whispers, secrets deep,\\nOf failing systems, promises\
\ to keep.\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\"\
: \"2025-03-07T22:19:26.378633Z\",\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"\\nBut tracing's light, a guiding hand,\\nReveals the path\"\
}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:26.378633Z\"\
,\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \", across the\
\ land.\\n\\nOpen Telemetry, a beacon bright,\\nIlluminating pathways, day\
\ and night.\\nFrom spans and traces, stories told,\"}]}}],\"modelVersion\"\
: \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:26.378633Z\"\
,\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\\nOf requests\
\ flowing, brave and bold.\\n\\nThe metrics rise, a vibrant chart,\\nDisplaying\
\ latency, a work of art.\\nEach request'\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:26.378633Z\",\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"s journey, clearly shown,\\nWhere bottlenecks slumber, seeds\
\ are sown.\\n\\nWith logs appended, context clear,\\nThe root of problems,\
\ drawing near.\\nObservability's embrace, so wide,\\nUnraveling mysteries,\"\
}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:26.378633Z\"\
,\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" deep inside.\\\
n\\nFrom simple apps to complex weaves,\\nOpen Telemetry's power achieves,\\\
nA unified vision, strong and true,\\nMonitoring systems, old and new.\\n\\\
nNo vendor lock-in, free to roam,\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:26.378633Z\",\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"\\nAcross the clouds, and find your home.\\nA standard rising,\
\ strong and bold,\\nA future brighter, to behold.\\n\\nSo let the traces\
\ flow and gleam,\\nOpen Telemetry, a vibrant dream.\\nOf healthy systems,\
\ running free,\\nFor all to see, for all to be.\"}]}}],\"modelVersion\":\
\ \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:26.378633Z\"\
,\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\\n\"}]},\"\
finishReason\": \"STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 8,\"\
candidatesTokenCount\": 258,\"totalTokenCount\": 266,\"promptTokensDetails\"\
: [{\"modality\": \"TEXT\",\"tokenCount\": 8}],\"candidatesTokensDetails\"\
: [{\"modality\": \"TEXT\",\"tokenCount\": 258}]},\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:26.378633Z\",\"responseId\": \"7nDLZ4mOF_Hg-O4P7YfKqQ8\"\
}\r\n\r\n"
headers:
Content-Disposition:
- attachment
Content-Type:
- text/event-stream
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -0,0 +1,99 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:streamGenerateContent?alt=sse
response:
body:
string: "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"No\"}]}}],\"usageMetadata\": {},\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:23.579184Z\",\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" more dark logs, a cryptic, silent scream,\\nNo more the hunt\
\ for\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"\
2025-03-07T22:19:23.579184Z\",\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" errors, a lost, fading dream.\\nOpen Telemetry, a beacon\
\ in\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"\
2025-03-07T22:19:23.579184Z\",\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" the night,\\nShining forth its data, clear and burning bright.\\\
n\\nFrom traces spanning systems, a flowing, silver thread,\\nMetrics pulse\
\ and measure,\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\"\
: \"2025-03-07T22:19:23.579184Z\",\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" insights finely spread.\\nLogs enriched with context, a story\
\ they unfold,\\nOf requests and responses, both brave and bold.\\n\\nObservability's\
\ promise\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\"\
: \"2025-03-07T22:19:23.579184Z\",\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \", a future now at hand,\\nWith vendors interoperable, a collaborative\
\ band.\\nNo longer vendor lock-in, a restrictive, iron cage,\\nBut freedom\
\ of selection, turning a new page.\\n\\nFrom microservices humming,\"}]}}],\"\
modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:23.579184Z\"\
,\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" a symphony\
\ of calls,\\nTo monolithic giants, answering their thralls,\\nOpen Telemetry\
\ watches, with keen and watchful eye,\\nDetecting the anomalies, before they\
\ rise and fly.\\n\\nSo let the data flow freely, a\"}]}}],\"modelVersion\"\
: \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:23.579184Z\"\
,\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \" river strong\
\ and deep,\\nIts secrets it will whisper, while the systems sleep.\\nOpen\
\ Telemetry's power, a force that we can wield,\\nTo build more stable systems,\
\ in the digital field.\\n\"}]},\"finishReason\": \"STOP\"}],\"usageMetadata\"\
: {\"promptTokenCount\": 8,\"candidatesTokenCount\": 238,\"totalTokenCount\"\
: 246,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 8}],\"\
candidatesTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 238}]},\"\
modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:23.579184Z\"\
,\"responseId\": \"63DLZ_CsI_Hg-O4P7YfKqQ8\"}\r\n\r\n"
headers:
Content-Disposition:
- attachment
Content-Type:
- text/event-stream
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -0,0 +1,99 @@
interactions:
- request:
body: |-
{
"contents": [
{
"parts": [
{
"text": "Create a poem about Open Telemetry."
}
],
"role": "user"
}
]
}
headers:
Accept:
- '*/*'
Accept-Encoding:
- gzip, deflate
Connection:
- keep-alive
Content-Length:
- '92'
Content-Type:
- application/json
user-agent:
- google-genai-sdk/1.0.0 gl-python/3.12.8
x-goog-api-client:
- <REDACTED>
x-goog-user-project:
- <REDACTED>
method: POST
uri: https://test-location-aiplatform.googleapis.com/v1beta1/projects/test-project/locations/test-location/publishers/google/models/gemini-1.5-flash-002:streamGenerateContent?alt=sse
response:
body:
string: "data: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"No\"}]}}],\"usageMetadata\": {},\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:20.770456Z\",\"responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \" more dark, mysterious traces,\\nNo more guessing, in time\
\ and spaces.\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\"\
: \"2025-03-07T22:19:20.770456Z\",\"responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"\\nOpen Telemetry's light shines bright,\\nIlluminating the\
\ code'\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\":\
\ \"2025-03-07T22:19:20.770456Z\",\"responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"s dark night.\\n\\nFrom spans and metrics, a story told,\\\
nOf requests flowing, both brave and bold.\\nTraces weaving, a tapestry grand,\"\
}]}}],\"modelVersion\": \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:20.770456Z\"\
,\"responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \"\\nShowing\
\ performance, across the land.\\n\\nLogs and metrics, a perfect blend,\\\
nInformation's flow, without end.\\nObservability's promise\"}]}}],\"modelVersion\"\
: \"gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:20.770456Z\"\
,\"responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"}\r\n\r\ndata: {\"candidates\"\
: [{\"content\": {\"role\": \"model\",\"parts\": [{\"text\": \", clear and\
\ true,\\nInsights revealed, for me and you.\\n\\nJaeger, Zipkin, a chorus\
\ sings,\\nWith exporters ready, for all the things.\\nFrom simple apps to\
\ systems vast,\\nOpen Telemetry'\"}]}}],\"modelVersion\": \"gemini-1.5-flash-002\"\
,\"createTime\": \"2025-03-07T22:19:20.770456Z\",\"responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"\
}\r\n\r\ndata: {\"candidates\": [{\"content\": {\"role\": \"model\",\"parts\"\
: [{\"text\": \"s power will last.\\n\\nNo vendor lock-in, a freedom sweet,\\\
nOpen source glory, can't be beat.\\nSo let us embrace, this modern way,\\\
nTo monitor systems, come what may.\\n\\nFrom\"}]}}],\"modelVersion\": \"\
gemini-1.5-flash-002\",\"createTime\": \"2025-03-07T22:19:20.770456Z\",\"\
responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"}\r\n\r\ndata: {\"candidates\": [{\"\
content\": {\"role\": \"model\",\"parts\": [{\"text\": \" microservices, small\
\ and slight,\\nTo monolithic giants, shining bright,\\nOpen Telemetry shows\
\ the path,\\nTo understand, and fix the wrath,\\nOf latency demons, lurking\
\ near,\\nBringing clarity, year after year.\\n\"}]},\"finishReason\": \"\
STOP\"}],\"usageMetadata\": {\"promptTokenCount\": 8,\"candidatesTokenCount\"\
: 242,\"totalTokenCount\": 250,\"promptTokensDetails\": [{\"modality\": \"\
TEXT\",\"tokenCount\": 8}],\"candidatesTokensDetails\": [{\"modality\": \"\
TEXT\",\"tokenCount\": 242}]},\"modelVersion\": \"gemini-1.5-flash-002\",\"\
createTime\": \"2025-03-07T22:19:20.770456Z\",\"responseId\": \"6HDLZ5iDL86F3NoPzaTkiQU\"\
}\r\n\r\n"
headers:
Content-Disposition:
- attachment
Content-Type:
- text/event-stream
Transfer-Encoding:
- chunked
Vary:
- Origin
- X-Origin
- Referer
X-Content-Type-Options:
- nosniff
X-Frame-Options:
- SAMEORIGIN
X-XSS-Protection:
- '0'
status:
code: 200
message: OK
version: 1

View File

@ -16,8 +16,7 @@ import json
import os
import unittest
from ..common.base import TestCase
from .util import create_valid_response
from .base import TestCase
class NonStreamingTestCase(TestCase):
@ -36,18 +35,15 @@ class NonStreamingTestCase(TestCase):
def expected_function_name(self):
raise NotImplementedError("Must implement 'expected_function_name'.")
def configure_valid_response(self, *args, **kwargs):
self.requests.add_response(create_valid_response(*args, **kwargs))
def test_instrumentation_does_not_break_core_functionality(self):
self.configure_valid_response(response_text="Yep, it works!")
self.configure_valid_response(text="Yep, it works!")
response = self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)
self.assertEqual(response.text, "Yep, it works!")
def test_generates_span(self):
self.configure_valid_response(response_text="Yep, it works!")
self.configure_valid_response(text="Yep, it works!")
response = self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)
@ -55,7 +51,7 @@ class NonStreamingTestCase(TestCase):
self.otel.assert_has_span_named("generate_content gemini-2.0-flash")
def test_model_reflected_into_span_name(self):
self.configure_valid_response(response_text="Yep, it works!")
self.configure_valid_response(text="Yep, it works!")
response = self.generate_content(
model="gemini-1.5-flash", contents="Does this work?"
)
@ -63,7 +59,7 @@ class NonStreamingTestCase(TestCase):
self.otel.assert_has_span_named("generate_content gemini-1.5-flash")
def test_generated_span_has_minimal_genai_attributes(self):
self.configure_valid_response(response_text="Yep, it works!")
self.configure_valid_response(text="Yep, it works!")
self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)
@ -75,7 +71,7 @@ class NonStreamingTestCase(TestCase):
)
def test_generated_span_has_correct_function_name(self):
self.configure_valid_response(response_text="Yep, it works!")
self.configure_valid_response(text="Yep, it works!")
self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)
@ -87,7 +83,7 @@ class NonStreamingTestCase(TestCase):
def test_generated_span_has_vertex_ai_system_when_configured(self):
self.set_use_vertex(True)
self.configure_valid_response(response_text="Yep, it works!")
self.configure_valid_response(text="Yep, it works!")
self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)
@ -170,7 +166,7 @@ class NonStreamingTestCase(TestCase):
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = (
"true"
)
self.configure_valid_response(response_text="Some response content")
self.configure_valid_response(text="Some response content")
self.generate_content(model="gemini-2.0-flash", contents="Some input")
self.otel.assert_has_event_named("gen_ai.choice")
event_record = self.otel.get_event_named("gen_ai.choice")
@ -183,7 +179,7 @@ class NonStreamingTestCase(TestCase):
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = (
"false"
)
self.configure_valid_response(response_text="Some response content")
self.configure_valid_response(text="Some response content")
self.generate_content(model="gemini-2.0-flash", contents="Some input")
self.otel.assert_has_event_named("gen_ai.choice")
event_record = self.otel.get_event_named("gen_ai.choice")

View File

@ -14,8 +14,7 @@
import unittest
from ..common.base import TestCase
from .util import create_valid_response
from .base import TestCase
class StreamingTestCase(TestCase):
@ -34,11 +33,8 @@ class StreamingTestCase(TestCase):
def expected_function_name(self):
raise NotImplementedError("Must implement 'expected_function_name'.")
def configure_valid_response(self, *args, **kwargs):
self.requests.add_response(create_valid_response(*args, **kwargs))
def test_instrumentation_does_not_break_core_functionality(self):
self.configure_valid_response(response_text="Yep, it works!")
self.configure_valid_response(text="Yep, it works!")
responses = self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)
@ -47,8 +43,8 @@ class StreamingTestCase(TestCase):
self.assertEqual(response.text, "Yep, it works!")
def test_handles_multiple_ressponses(self):
self.configure_valid_response(response_text="First response")
self.configure_valid_response(response_text="Second response")
self.configure_valid_response(text="First response")
self.configure_valid_response(text="Second response")
responses = self.generate_content(
model="gemini-2.0-flash", contents="Does this work?"
)

View File

@ -0,0 +1,504 @@
# Copyright The OpenTelemetry Authors
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""High level end-to-end test of the generate content instrumentation.
The primary purpose of this test is to verify that the instrumentation
package does not break the underlying GenAI SDK that it instruments.
This test suite also has some minimal validation of the instrumentation
outputs; however, validating the instrumentation output (other than
verifying that instrumentation does not break the GenAI SDK) is a
secondary goal of this test. Detailed testing of the instrumentation
output is the purview of the other tests in this directory."""
import asyncio
import gzip
import json
import os
import subprocess
import sys
import google.auth
import google.auth.credentials
import google.genai
import pytest
import yaml
from vcr.record_mode import RecordMode
from opentelemetry.instrumentation.google_genai import (
GoogleGenAiSdkInstrumentor,
)
from ..common.auth import FakeCredentials
from ..common.otel_mocker import OTelMocker
_FAKE_PROJECT = "test-project"
_FAKE_LOCATION = "test-location"
_FAKE_API_KEY = "test-api-key"
_DEFAULT_REAL_LOCATION = "us-central1"
def _get_project_from_env():
return (
os.getenv("GCLOUD_PROJECT") or os.getenv("GOOGLE_CLOUD_PROJECT") or ""
)
def _get_project_from_gcloud_cli():
try:
gcloud_call_result = subprocess.run(
"gcloud config get project",
shell=True,
capture_output=True,
check=True,
)
except subprocess.CalledProcessError:
return None
gcloud_output = gcloud_call_result.stdout.decode()
return gcloud_output.strip()
def _get_project_from_credentials():
_, from_creds = google.auth.default()
return from_creds
def _get_real_project():
from_env = _get_project_from_env()
if from_env:
return from_env
from_cli = _get_project_from_gcloud_cli()
if from_cli:
return from_cli
return _get_project_from_credentials()
def _get_location_from_env():
return (
os.getenv("GCLOUD_LOCATION")
or os.getenv("GOOGLE_CLOUD_LOCATION")
or ""
)
def _get_real_location():
return _get_location_from_env() or _DEFAULT_REAL_LOCATION
def _get_vertex_api_key_from_env():
return os.getenv("GOOGLE_API_KEY")
def _get_gemini_api_key_from_env():
return os.getenv("GEMINI_API_KEY")
def _should_redact_header(header_key):
if header_key.startswith("x-goog"):
return True
if header_key.startswith("sec-goog"):
return True
if header_key in ["server", "server-timing"]:
return True
return False
def _redact_headers(headers):
for header_key in headers:
if _should_redact_header(header_key.lower()):
headers[header_key] = "<REDACTED>"
def _before_record_request(request):
if request.headers:
_redact_headers(request.headers)
uri = request.uri
project = _get_project_from_env()
if project:
uri = uri.replace(f"projects/{project}", f"projects/{_FAKE_PROJECT}")
location = _get_real_location()
if location:
uri = uri.replace(
f"locations/{location}", f"locations/{_FAKE_LOCATION}"
)
uri = uri.replace(
f"//{location}-aiplatform.googleapis.com",
f"//{_FAKE_LOCATION}-aiplatform.googleapis.com",
)
request.uri = uri
return request
def _before_record_response(response):
if hasattr(response, "headers") and response.headers:
_redact_headers(response.headers)
return response
@pytest.fixture(name="vcr_config", scope="module")
def fixture_vcr_config():
return {
"filter_query_parameters": [
"key",
"apiKey",
"quotaUser",
"userProject",
"token",
"access_token",
"accessToken",
"refesh_token",
"refreshToken",
"authuser",
"bearer",
"bearer_token",
"bearerToken",
"userIp",
],
"filter_post_data_parameters": ["apikey", "api_key", "key"],
"filter_headers": [
"x-goog-api-key",
"authorization",
"server",
"Server",
"Server-Timing",
"Date",
],
"before_record_request": _before_record_request,
"before_record_response": _before_record_response,
"ignore_hosts": [
"oauth2.googleapis.com",
"iam.googleapis.com",
],
}
class _LiteralBlockScalar(str):
"""Formats the string as a literal block scalar, preserving whitespace and
without interpreting escape characters"""
def _literal_block_scalar_presenter(dumper, data):
"""Represents a scalar string as a literal block, via '|' syntax"""
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
@pytest.fixture(
name="internal_setup_yaml_pretty_formatting", scope="module", autouse=True
)
def fixture_setup_yaml_pretty_formatting():
yaml.add_representer(_LiteralBlockScalar, _literal_block_scalar_presenter)
def _process_string_value(string_value):
"""Pretty-prints JSON or returns long strings as a LiteralBlockScalar"""
try:
json_data = json.loads(string_value)
return _LiteralBlockScalar(json.dumps(json_data, indent=2))
except (ValueError, TypeError):
if len(string_value) > 80:
return _LiteralBlockScalar(string_value)
return string_value
def _convert_body_to_literal(data):
"""Searches the data for body strings, attempting to pretty-print JSON"""
if isinstance(data, dict):
for key, value in data.items():
# Handle response body case (e.g., response.body.string)
if key == "body" and isinstance(value, dict) and "string" in value:
value["string"] = _process_string_value(value["string"])
# Handle request body case (e.g., request.body)
elif key == "body" and isinstance(value, str):
data[key] = _process_string_value(value)
else:
_convert_body_to_literal(value)
elif isinstance(data, list):
for idx, choice in enumerate(data):
data[idx] = _convert_body_to_literal(choice)
return data
# Helper for enforcing GZIP compression where it was originally.
def _ensure_gzip_single_response(data: bytes):
try:
# Attempt to decompress, first, to avoid double compression.
gzip.decompress(data)
return data
except gzip.BadGzipFile:
# It must not have been compressed in the first place.
return gzip.compress(data)
# VCRPy automatically decompresses responses before saving them, but it may forget to
# re-encode them when the data is loaded. This can create issues with decompression.
# This is why we re-encode on load; to accurately replay what was originally sent.
#
# https://vcrpy.readthedocs.io/en/latest/advanced.html#decode-compressed-response
def _ensure_casette_gzip(loaded_casette):
for interaction in loaded_casette["interactions"]:
response = interaction["response"]
headers = response["headers"]
if (
"content-encoding" not in headers
and "Content-Encoding" not in headers
):
continue
if (
"content-encoding" in headers
and "gzip" not in headers["content-encoding"]
):
continue
if (
"Content-Encoding" in headers
and "gzip" not in headers["Content-Encoding"]
):
continue
response["body"]["string"] = _ensure_gzip_single_response(
response["body"]["string"].encode()
)
def _maybe_ensure_casette_gzip(result):
if sys.version_info[0] == 3 and sys.version_info[1] == 9:
_ensure_casette_gzip(result)
class _PrettyPrintJSONBody:
"""This makes request and response body recordings more readable."""
@staticmethod
def serialize(cassette_dict):
cassette_dict = _convert_body_to_literal(cassette_dict)
return yaml.dump(
cassette_dict, default_flow_style=False, allow_unicode=True
)
@staticmethod
def deserialize(cassette_string):
result = yaml.load(cassette_string, Loader=yaml.Loader)
_maybe_ensure_casette_gzip(result)
return result
@pytest.fixture(name="fully_initialized_vcr", scope="module", autouse=True)
def setup_vcr(vcr):
vcr.register_serializer("yaml", _PrettyPrintJSONBody)
vcr.serializer = "yaml"
return vcr
@pytest.fixture(name="instrumentor")
def fixture_instrumentor():
return GoogleGenAiSdkInstrumentor()
@pytest.fixture(name="internal_instrumentation_setup", autouse=True)
def fixture_setup_instrumentation(instrumentor):
instrumentor.instrument()
yield
instrumentor.uninstrument()
@pytest.fixture(name="otel_mocker", autouse=True)
def fixture_otel_mocker():
result = OTelMocker()
result.install()
yield result
result.uninstall()
@pytest.fixture(
name="setup_content_recording",
autouse=True,
params=["logcontent", "excludecontent"],
)
def fixture_setup_content_recording(request):
enabled = request.param == "logcontent"
os.environ["OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT"] = str(
enabled
)
@pytest.fixture(name="vcr_record_mode")
def fixture_vcr_record_mode(vcr):
return vcr.record_mode
@pytest.fixture(name="in_replay_mode")
def fixture_in_replay_mode(vcr_record_mode):
return vcr_record_mode == RecordMode.NONE
@pytest.fixture(name="gcloud_project", autouse=True)
def fixture_gcloud_project(in_replay_mode):
if in_replay_mode:
return _FAKE_PROJECT
result = _get_real_project()
for env_var in ["GCLOUD_PROJECT", "GOOGLE_CLOUD_PROJECT"]:
os.environ[env_var] = result
return result
@pytest.fixture(name="gcloud_location")
def fixture_gcloud_location(in_replay_mode):
if in_replay_mode:
return _FAKE_LOCATION
return _get_real_location()
@pytest.fixture(name="gcloud_credentials")
def fixture_gcloud_credentials(in_replay_mode):
if in_replay_mode:
return FakeCredentials()
creds, _ = google.auth.default()
return google.auth.credentials.with_scopes_if_required(
creds, ["https://www.googleapis.com/auth/cloud-platform"]
)
@pytest.fixture(name="gemini_api_key")
def fixture_gemini_api_key(in_replay_mode):
if in_replay_mode:
return _FAKE_API_KEY
return os.getenv("GEMINI_API_KEY")
@pytest.fixture(name="gcloud_api_key", autouse=True)
def fixture_gcloud_api_key(gemini_api_key):
if "GOOGLE_API_KEY" not in os.environ:
os.environ["GOOGLE_API_KEY"] = gemini_api_key
return os.getenv("GOOGLE_API_KEY")
@pytest.fixture(name="nonvertex_client_factory")
def fixture_nonvertex_client_factory(gemini_api_key):
def _factory():
return google.genai.Client(api_key=gemini_api_key, vertexai=False)
return _factory
@pytest.fixture(name="vertex_client_factory")
def fixture_vertex_client_factory(
gcloud_project, gcloud_location, gcloud_credentials
):
def _factory():
return google.genai.Client(
vertexai=True,
project=gcloud_project,
location=gcloud_location,
credentials=gcloud_credentials,
)
return _factory
@pytest.fixture(name="genai_sdk_backend", params=["vertexaiapi"])
def fixture_genai_sdk_backend(request):
return request.param
@pytest.fixture(name="use_vertex", autouse=True)
def fixture_use_vertex(genai_sdk_backend):
result = bool(genai_sdk_backend == "vertexaiapi")
os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "1" if result else "0"
return result
@pytest.fixture(name="client")
def fixture_client(
vertex_client_factory, nonvertex_client_factory, use_vertex
):
if use_vertex:
return vertex_client_factory()
return nonvertex_client_factory()
@pytest.fixture(name="is_async", params=["sync", "async"])
def fixture_is_async(request):
return request.param == "async"
@pytest.fixture(name="model", params=["gemini-1.5-flash-002"])
def fixture_model(request):
return request.param
@pytest.fixture(name="generate_content")
def fixture_generate_content(client, is_async):
def _sync_impl(*args, **kwargs):
return client.models.generate_content(*args, **kwargs)
def _async_impl(*args, **kwargs):
return asyncio.run(client.aio.models.generate_content(*args, **kwargs))
if is_async:
return _async_impl
return _sync_impl
@pytest.fixture(name="generate_content_stream")
def fixture_generate_content_stream(client, is_async):
def _sync_impl(*args, **kwargs):
results = []
for result in client.models.generate_content_stream(*args, **kwargs):
results.append(result)
return results
def _async_impl(*args, **kwargs):
async def _gather_all():
results = []
async for (
result
) in await client.aio.models.generate_content_stream(
*args, **kwargs
):
results.append(result)
return results
return asyncio.run(_gather_all())
if is_async:
return _async_impl
return _sync_impl
@pytest.mark.vcr
def test_non_streaming(generate_content, model, otel_mocker):
response = generate_content(
model=model, contents="Create a poem about Open Telemetry."
)
assert response is not None
assert response.text is not None
assert len(response.text) > 0
otel_mocker.assert_has_span_named(f"generate_content {model}")
@pytest.mark.vcr
def test_streaming(generate_content_stream, model, otel_mocker):
count = 0
for response in generate_content_stream(
model=model, contents="Create a poem about Open Telemetry."
):
assert response is not None
assert response.text is not None
assert len(response.text) > 0
count += 1
assert count > 0
otel_mocker.assert_has_span_named(f"generate_content {model}")

View File

@ -12,27 +12,64 @@
# See the License for the specific language governing permissions and
# limitations under the License.
from typing import Optional, Union
def create_valid_response(
response_text="The model response", input_tokens=10, output_tokens=20
):
return {
"modelVersion": "gemini-2.0-flash-test123",
"usageMetadata": {
"promptTokenCount": input_tokens,
"candidatesTokenCount": output_tokens,
"totalTokenCount": input_tokens + output_tokens,
},
"candidates": [
{
"content": {
"role": "model",
"parts": [
{
"text": response_text,
}
],
}
}
],
}
import google.genai.types as genai_types
def create_response(
part: Optional[genai_types.Part] = None,
parts: Optional[list[genai_types.Part]] = None,
content: Optional[genai_types.Content] = None,
candidate: Optional[genai_types.Candidate] = None,
candidates: Optional[list[genai_types.Candidate]] = None,
text: Optional[str] = None,
input_tokens: Optional[int] = None,
output_tokens: Optional[int] = None,
model_version: Optional[str] = None,
usage_metadata: Optional[
genai_types.GenerateContentResponseUsageMetadata
] = None,
**kwargs,
) -> genai_types.GenerateContentResponse:
# Build up the "candidates" subfield
if text is None:
text = "Some response text"
if part is None:
part = genai_types.Part(text=text)
if parts is None:
parts = [part]
if content is None:
content = genai_types.Content(parts=parts, role="model")
if candidate is None:
candidate = genai_types.Candidate(content=content)
if candidates is None:
candidates = [candidate]
# Build up the "usage_metadata" subfield
if usage_metadata is None:
usage_metadata = genai_types.GenerateContentResponseUsageMetadata()
if input_tokens is not None:
usage_metadata.prompt_token_count = input_tokens
if output_tokens is not None:
usage_metadata.candidates_token_count = output_tokens
return genai_types.GenerateContentResponse(
candidates=candidates,
usage_metadata=usage_metadata,
model_version=model_version,
**kwargs,
)
def convert_to_response(
arg: Union[str, genai_types.GenerateContentResponse, dict],
) -> genai_types.GenerateContentResponse:
if isinstance(arg, str):
return create_response(text=arg)
if isinstance(arg, genai_types.GenerateContentResponse):
return arg
if isinstance(arg, dict):
return create_response(**arg)
raise ValueError(
f"Unsure how to convert {arg} of type {arg.__class__.__name__} to response."
)