From 6202371668f106a04fea3cca064f8088e1cc1244 Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Sun, 12 Apr 2026 23:02:05 -0700 Subject: [PATCH 1/7] feat: Add Cohere instrumentation for chat completions Implements initial Cohere instrumentation following the GenAI semantic conventions. Supports sync and async chat completions with token usage, content capture, and error handling. Streaming support will follow in a separate PR. Ref #3050 --- .../LICENSE | 201 ++++++++++++++ .../README.rst | 41 +++ .../pyproject.toml | 58 ++++ .../instrumentation/cohere/__init__.py | 105 ++++++++ .../instrumentation/cohere/package.py | 18 ++ .../instrumentation/cohere/patch.py | 76 ++++++ .../instrumentation/cohere/utils.py | 229 ++++++++++++++++ .../instrumentation/cohere/version.py | 15 ++ .../tests/__init__.py | 13 + .../tests/conftest.py | 133 +++++++++ .../tests/test_async_chat_completions.py | 137 ++++++++++ .../tests/test_chat_completions.py | 252 ++++++++++++++++++ .../tests/test_utils.py | 70 +++++ 13 files changed, 1348 insertions(+) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/LICENSE create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/version.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/__init__.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/LICENSE b/instrumentation-genai/opentelemetry-instrumentation-cohere/LICENSE new file mode 100644 index 0000000000..261eeb9e9f --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + 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. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst new file mode 100644 index 0000000000..2ae12a795d --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst @@ -0,0 +1,41 @@ +OpenTelemetry Cohere Instrumentation +===================================== + +|pypi| + +.. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-cohere.svg + :target: https://pypi.org/project/opentelemetry-instrumentation-cohere/ + +This library allows tracing applications that use the `Cohere Python SDK `_. + +Installation +------------ + +:: + + pip install opentelemetry-instrumentation-cohere + +Usage +----- + +.. code-block:: python + + from cohere import ClientV2 + from opentelemetry.instrumentation.cohere import CohereInstrumentor + + CohereInstrumentor().instrument() + + client = ClientV2() + response = client.chat( + model="command-r-plus", + messages=[ + {"role": "user", "content": "Hello, how are you?"}, + ], + ) + +References +---------- + +* `OpenTelemetry Cohere Instrumentation `_ +* `OpenTelemetry Project `_ +* `OpenTelemetry Python Examples `_ diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml new file mode 100644 index 0000000000..09aa4ed3dd --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml @@ -0,0 +1,58 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "opentelemetry-instrumentation-cohere" +dynamic = ["version"] +description = "OpenTelemetry Cohere instrumentation" +readme = "README.rst" +license = "Apache-2.0" +requires-python = ">=3.9" +authors = [ + { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, +] +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", +] +dependencies = [ + "opentelemetry-api ~= 1.39", + "opentelemetry-instrumentation ~= 0.60b0", + "opentelemetry-semantic-conventions ~= 0.60b0", + "opentelemetry-util-genai", +] + +[project.optional-dependencies] +instruments = ["cohere >= 5.0.0"] +test = [ + "opentelemetry-instrumentation-cohere[instruments]", + "opentelemetry-sdk", + "opentelemetry-test-utils", + "pytest", +] + +[project.entry-points.opentelemetry_instrumentor] +cohere = "opentelemetry.instrumentation.cohere:CohereInstrumentor" + +[project.urls] +Homepage = "https://github.com/open-telemetry/opentelemetry-python-contrib/tree/main/instrumentation-genai/opentelemetry-instrumentation-cohere" +Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" + +[tool.hatch.version] +path = "src/opentelemetry/instrumentation/cohere/version.py" + +[tool.hatch.build.targets.sdist] +include = ["/src", "/tests"] + +[tool.hatch.build.targets.wheel] +packages = ["src/opentelemetry"] diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py new file mode 100644 index 0000000000..227cdeb0da --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py @@ -0,0 +1,105 @@ +# 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. + +""" +Cohere client instrumentation supporting `cohere`, it can be enabled by +using ``CohereInstrumentor``. + +.. _cohere: https://pypi.org/project/cohere/ + +Usage +----- + +.. code:: python + + from cohere import ClientV2 + from opentelemetry.instrumentation.cohere import CohereInstrumentor + + CohereInstrumentor().instrument() + + client = ClientV2() + response = client.chat( + model="command-r-plus", + messages=[ + {"role": "user", "content": "Write a short poem on open telemetry."}, + ], + ) + +API +--- +""" + +from typing import Collection + +from wrapt import wrap_function_wrapper + +from opentelemetry.instrumentation.cohere.package import _instruments +from opentelemetry.instrumentation.instrumentor import BaseInstrumentor +from opentelemetry.instrumentation.utils import unwrap +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.types import ContentCapturingMode +from opentelemetry.util.genai.utils import ( + get_content_capturing_mode, + is_experimental_mode, +) + +from .patch import ( + async_chat_create, + chat_create, +) + + +class CohereInstrumentor(BaseInstrumentor): + def instrumentation_dependencies(self) -> Collection[str]: + return _instruments + + def _instrument(self, **kwargs): + """Enable Cohere instrumentation.""" + tracer_provider = kwargs.get("tracer_provider") + meter_provider = kwargs.get("meter_provider") + logger_provider = kwargs.get("logger_provider") + + latest_experimental_enabled = is_experimental_mode() + content_mode = ( + get_content_capturing_mode() + if latest_experimental_enabled + else ContentCapturingMode.NO_CONTENT + ) + + handler = TelemetryHandler( + tracer_provider=tracer_provider, + meter_provider=meter_provider, + logger_provider=logger_provider, + ) + + # Instrument sync V2Client.chat + wrap_function_wrapper( + module="cohere.v2.client", + name="V2Client.chat", + wrapper=chat_create(handler, content_mode), + ) + + # Instrument async AsyncV2Client.chat + wrap_function_wrapper( + module="cohere.v2.client", + name="AsyncV2Client.chat", + wrapper=async_chat_create(handler, content_mode), + ) + + + def _uninstrument(self, **kwargs): + import cohere.v2.client # pylint: disable=import-outside-toplevel + + unwrap(cohere.v2.client.V2Client, "chat") + unwrap(cohere.v2.client.AsyncV2Client, "chat") diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py new file mode 100644 index 0000000000..60ee8acda8 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py @@ -0,0 +1,18 @@ +# 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. + + +_instruments = ("cohere >= 5.0.0",) + +_supports_metrics = True diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py new file mode 100644 index 0000000000..0d1077bdf1 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py @@ -0,0 +1,76 @@ +# 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. + +from __future__ import annotations + +from opentelemetry.util.genai.handler import TelemetryHandler +from opentelemetry.util.genai.types import ( + ContentCapturingMode, + Error, +) + +from .utils import ( + create_chat_invocation, + set_response_attributes, +) + + +def chat_create( + handler: TelemetryHandler, + content_capturing_mode: ContentCapturingMode, +): + """Wrap ``V2Client.chat`` to emit GenAI telemetry.""" + capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT + + def traced_method(wrapped, instance, args, kwargs): + invocation = handler.start_llm( + create_chat_invocation(kwargs, instance, capture_content=capture_content) + ) + try: + result = wrapped(*args, **kwargs) + set_response_attributes(invocation, result, capture_content) + handler.stop_llm(invocation) + return result + except Exception as error: + handler.fail_llm( + invocation, Error(type=type(error), message=str(error)) + ) + raise + + return traced_method + + +def async_chat_create( + handler: TelemetryHandler, + content_capturing_mode: ContentCapturingMode, +): + """Wrap ``AsyncV2Client.chat`` to emit GenAI telemetry.""" + capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT + + async def traced_method(wrapped, instance, args, kwargs): + invocation = handler.start_llm( + create_chat_invocation(kwargs, instance, capture_content=capture_content) + ) + try: + result = await wrapped(*args, **kwargs) + set_response_attributes(invocation, result, capture_content) + handler.stop_llm(invocation) + return result + except Exception as error: + handler.fail_llm( + invocation, Error(type=type(error), message=str(error)) + ) + raise + + return traced_method diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py new file mode 100644 index 0000000000..0655bbc37a --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py @@ -0,0 +1,229 @@ +# 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. + +from __future__ import annotations + +from typing import Any, List, Optional + +from opentelemetry.util.genai.types import ( + InputMessage, + LLMInvocation, + OutputMessage, + Text, + ToolCallRequest, +) + +COHERE_PROVIDER_NAME = "cohere" + +# Mapping from Cohere finish reasons to GenAI semantic convention finish reasons +_FINISH_REASON_MAP = { + "COMPLETE": "stop", + "STOP_SEQUENCE": "stop", + "MAX_TOKENS": "length", + "TOOL_CALL": "tool_calls", + "ERROR": "error", + "TIMEOUT": "error", +} + + +def map_finish_reason(cohere_reason: Optional[str]) -> str: + if cohere_reason is None: + return "error" + return _FINISH_REASON_MAP.get(str(cohere_reason), str(cohere_reason).lower()) + + +def get_server_address_and_port( + client_instance: Any, +) -> tuple[Optional[str], Optional[int]]: + """Extract server address and port from the Cohere client instance.""" + base_url = getattr(client_instance, "base_url", None) + if base_url is None: + # Check nested _client pattern + inner = getattr(client_instance, "_client", None) + if inner is not None: + base_url = getattr(inner, "base_url", None) + + if base_url is None: + return "api.cohere.com", None + + if isinstance(base_url, str): + from urllib.parse import urlparse + + parsed = urlparse(base_url) + address = parsed.hostname or "api.cohere.com" + port = parsed.port + if port == 443: + port = None + return address, port + + return "api.cohere.com", None + + +def create_chat_invocation( + kwargs: dict[str, Any], + client_instance: Any, + capture_content: bool, +) -> LLMInvocation: + """Create an LLMInvocation from Cohere chat kwargs.""" + invocation = LLMInvocation(request_model=kwargs.get("model", "")) + invocation.provider = COHERE_PROVIDER_NAME + invocation.temperature = kwargs.get("temperature") + invocation.top_p = kwargs.get("p") + invocation.max_tokens = kwargs.get("max_tokens") + invocation.frequency_penalty = kwargs.get("frequency_penalty") + invocation.presence_penalty = kwargs.get("presence_penalty") + invocation.seed = kwargs.get("seed") + + stop_sequences = kwargs.get("stop_sequences") + if stop_sequences is not None: + if isinstance(stop_sequences, str): + stop_sequences = [stop_sequences] + invocation.stop_sequences = list(stop_sequences) + + address, port = get_server_address_and_port(client_instance) + if address: + invocation.server_address = address + if port: + invocation.server_port = port + + if capture_content: + invocation.input_messages = _extract_input_messages( + kwargs.get("messages", []) + ) + + return invocation + + +def set_response_attributes( + invocation: LLMInvocation, + response: Any, + capture_content: bool, +) -> None: + """Populate invocation attributes from a Cohere V2ChatResponse.""" + if response is None: + return + + response_id = getattr(response, "id", None) + if response_id: + invocation.response_id = response_id + + finish_reason = getattr(response, "finish_reason", None) + if finish_reason is not None: + invocation.finish_reasons = [map_finish_reason(finish_reason)] + + usage = getattr(response, "usage", None) + if usage is not None: + _set_usage(invocation, usage) + + if capture_content: + message = getattr(response, "message", None) + if message is not None: + invocation.output_messages = _extract_output_messages( + message, finish_reason + ) + + +def _set_usage(invocation: LLMInvocation, usage: Any) -> None: + """Extract token usage from Cohere Usage object.""" + tokens = getattr(usage, "tokens", None) + if tokens is not None: + input_tokens = getattr(tokens, "input_tokens", None) + if input_tokens is not None: + invocation.input_tokens = int(input_tokens) + output_tokens = getattr(tokens, "output_tokens", None) + if output_tokens is not None: + invocation.output_tokens = int(output_tokens) + return + + # Fallback to billed_units + billed = getattr(usage, "billed_units", None) + if billed is not None: + input_tokens = getattr(billed, "input_tokens", None) + if input_tokens is not None: + invocation.input_tokens = int(input_tokens) + output_tokens = getattr(billed, "output_tokens", None) + if output_tokens is not None: + invocation.output_tokens = int(output_tokens) + + +def _extract_input_messages( + messages: List[Any], +) -> List[InputMessage]: + """Convert Cohere chat messages to InputMessage list.""" + result = [] + for msg in messages: + if isinstance(msg, dict): + role = msg.get("role", "user") + content = msg.get("content", "") + else: + role = getattr(msg, "role", "user") + content = getattr(msg, "content", "") + + parts: list = [] + if isinstance(content, str): + parts.append(Text(content=content)) + elif isinstance(content, list): + # Handle structured content items + for item in content: + if isinstance(item, dict): + text = item.get("text", "") + if text: + parts.append(Text(content=text)) + elif isinstance(item, str): + parts.append(Text(content=item)) + else: + text = getattr(item, "text", None) + if text: + parts.append(Text(content=str(text))) + + result.append(InputMessage(role=str(role), parts=parts)) + return result + + +def _extract_output_messages( + message: Any, + finish_reason: Any, +) -> List[OutputMessage]: + """Convert a Cohere AssistantMessageResponse to OutputMessage list.""" + parts: list = [] + + content_items = getattr(message, "content", None) + if content_items: + for item in content_items: + item_type = getattr(item, "type", None) + if item_type == "text": + text = getattr(item, "text", "") + parts.append(Text(content=str(text))) + + tool_calls = getattr(message, "tool_calls", None) + if tool_calls: + for tc in tool_calls: + tc_id = getattr(tc, "id", None) + tc_name = "" + tc_args = None + func = getattr(tc, "function", None) + if func: + tc_name = getattr(func, "name", "") or "" + tc_args = getattr(func, "arguments", None) + else: + tc_name = getattr(tc, "name", "") or "" + tc_args = getattr(tc, "parameters", None) + parts.append( + ToolCallRequest(id=tc_id, name=tc_name, arguments=tc_args) + ) + + role = getattr(message, "role", "assistant") or "assistant" + mapped_reason = map_finish_reason(finish_reason) + + return [OutputMessage(role=str(role), parts=parts, finish_reason=mapped_reason)] diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/version.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/version.py new file mode 100644 index 0000000000..e7bf4a48eb --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/version.py @@ -0,0 +1,15 @@ +# 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. + +__version__ = "0.1b0.dev" diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/__init__.py new file mode 100644 index 0000000000..b0a6f42841 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py new file mode 100644 index 0000000000..2d9c084eaf --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py @@ -0,0 +1,133 @@ +# 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. + +"""Test configuration for Cohere instrumentation tests.""" + +import os + +import pytest + +from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, +) +from opentelemetry.instrumentation.cohere import CohereInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.util.genai.environment_variables import ( + OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, +) + +try: + from opentelemetry.sdk._logs.export import ( + InMemoryLogRecordExporter, + SimpleLogRecordProcessor, + ) +except ImportError: + from opentelemetry.sdk._logs.export import ( + InMemoryLogExporter as InMemoryLogRecordExporter, + ) + from opentelemetry.sdk._logs.export import ( + SimpleLogRecordProcessor, + ) + +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +@pytest.fixture(scope="function", name="span_exporter") +def fixture_span_exporter(): + exporter = InMemorySpanExporter() + yield exporter + + +@pytest.fixture(scope="function", name="log_exporter") +def fixture_log_exporter(): + exporter = InMemoryLogRecordExporter() + yield exporter + + +@pytest.fixture(scope="function", name="metric_reader") +def fixture_metric_reader(): + reader = InMemoryMetricReader() + yield reader + + +@pytest.fixture(scope="function", name="tracer_provider") +def fixture_tracer_provider(span_exporter): + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + return provider + + +@pytest.fixture(scope="function", name="logger_provider") +def fixture_logger_provider(log_exporter): + provider = LoggerProvider() + provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter)) + return provider + + +@pytest.fixture(scope="function", name="meter_provider") +def fixture_meter_provider(metric_reader): + meter_provider = MeterProvider( + metric_readers=[metric_reader], + ) + return meter_provider + + +@pytest.fixture(autouse=True) +def environment(): + if not os.getenv("CO_API_KEY"): + os.environ["CO_API_KEY"] = "test_cohere_api_key" + + +@pytest.fixture(scope="function") +def instrument_no_content(tracer_provider, logger_provider, meter_provider): + _OpenTelemetrySemanticConventionStability._initialized = False + os.environ[OTEL_SEMCONV_STABILITY_OPT_IN] = "gen_ai_latest_experimental" + + instrumentor = CohereInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + yield instrumentor + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) + instrumentor.uninstrument() + + +@pytest.fixture(scope="function") +def instrument_with_content(tracer_provider, logger_provider, meter_provider): + _OpenTelemetrySemanticConventionStability._initialized = False + os.environ[OTEL_SEMCONV_STABILITY_OPT_IN] = "gen_ai_latest_experimental" + os.environ[OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT] = "span_only" + + instrumentor = CohereInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + yield instrumentor + os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) + instrumentor.uninstrument() diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py new file mode 100644 index 0000000000..cbf18c450c --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py @@ -0,0 +1,137 @@ +# 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. + +"""Tests for async Cohere chat completions instrumentation.""" + +import asyncio + +import httpx +import pytest + +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) + + +def _chat_response_json( + response_id="async-response-id", + finish_reason="COMPLETE", + content_text="Hello from async!", + input_tokens=10, + output_tokens=20, +): + return { + "id": response_id, + "finish_reason": finish_reason, + "message": { + "role": "assistant", + "content": [{"type": "text", "text": content_text}], + }, + "usage": { + "tokens": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + }, + }, + } + + +def _make_async_client(response_json=None, handler=None): + """Create a Cohere AsyncClientV2 with a mock HTTP transport.""" + if handler is None: + body = response_json or _chat_response_json() + + async def default_handler(request): + return httpx.Response(200, json=body) + + handler = default_handler + + transport = httpx.MockTransport(handler) + httpx_client = httpx.AsyncClient(transport=transport) + + from cohere import AsyncClientV2 + + return AsyncClientV2(api_key="test-key", httpx_client=httpx_client) + + +class TestAsyncChatCompletions: + """Test async chat completions.""" + + @pytest.mark.usefixtures("instrument_no_content") + def test_async_chat_basic(self, span_exporter): + async def run(): + client = _make_async_client() + response = await client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello async"}], + ) + return response + + response = asyncio.run(run()) + assert response.id == "async-response-id" + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.name == "chat command-r-plus" + attrs = dict(span.attributes) + assert attrs[GenAIAttributes.GEN_AI_OPERATION_NAME] == "chat" + assert attrs[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "command-r-plus" + assert attrs[GenAIAttributes.GEN_AI_PROVIDER_NAME] == "cohere" + assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] == 10 + assert attrs[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 + assert attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ("stop",) + + @pytest.mark.usefixtures("instrument_no_content") + def test_async_chat_error(self, span_exporter): + async def error_handler(request): + raise httpx.ConnectError("Async connection refused") + + async def run(): + client = _make_async_client(handler=error_handler) + await client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + + with pytest.raises(Exception): + asyncio.run(run()) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + assert spans[0].status.status_code.name == "ERROR" + + @pytest.mark.usefixtures("instrument_with_content") + def test_async_chat_with_content(self, span_exporter): + async def run(): + client = _make_async_client( + response_json=_chat_response_json( + content_text="Async content capture test" + ) + ) + await client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Test content"}], + ) + + asyncio.run(run()) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = dict(spans[0].attributes) + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES in attrs + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES in attrs + assert "Test content" in attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES] + assert "Async content capture test" in attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES] diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py new file mode 100644 index 0000000000..c4bd4c1ab3 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py @@ -0,0 +1,252 @@ +# 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. + +"""Tests for Cohere chat completions instrumentation.""" + +import httpx +import pytest + +from opentelemetry.instrumentation.cohere import CohereInstrumentor +from opentelemetry.semconv._incubating.attributes import ( + gen_ai_attributes as GenAIAttributes, +) +from opentelemetry.semconv.attributes import ( + server_attributes as ServerAttributes, +) + + +def _chat_response_json( + response_id="test-response-id", + finish_reason="COMPLETE", + content_text="Hello! How can I help you?", + input_tokens=10, + output_tokens=20, +): + return { + "id": response_id, + "finish_reason": finish_reason, + "message": { + "role": "assistant", + "content": [{"type": "text", "text": content_text}], + }, + "usage": { + "tokens": { + "input_tokens": input_tokens, + "output_tokens": output_tokens, + }, + }, + } + + +def _make_client(response_json=None, handler=None): + """Create a Cohere ClientV2 with a mock HTTP transport.""" + if handler is None: + body = response_json or _chat_response_json() + + def handler(request): + return httpx.Response(200, json=body) + + transport = httpx.MockTransport(handler) + httpx_client = httpx.Client(transport=transport) + + from cohere import ClientV2 + + return ClientV2(api_key="test-key", httpx_client=httpx_client) + + + +class TestChatCompletionsNoContent: + """Test sync chat completions without content capture.""" + + @pytest.mark.usefixtures("instrument_no_content") + def test_chat_basic(self, span_exporter): + client = _make_client() + response = client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + + assert response.id == "test-response-id" + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + + span = spans[0] + assert span.name == "chat command-r-plus" + attrs = dict(span.attributes) + assert attrs[GenAIAttributes.GEN_AI_OPERATION_NAME] == "chat" + assert attrs[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "command-r-plus" + assert attrs[GenAIAttributes.GEN_AI_PROVIDER_NAME] == "cohere" + assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] == 10 + assert attrs[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 + assert attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ("stop",) + assert attrs[GenAIAttributes.GEN_AI_RESPONSE_ID] == "test-response-id" + assert attrs[ServerAttributes.SERVER_ADDRESS] == "api.cohere.com" + + # No content should be captured + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in attrs + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in attrs + + @pytest.mark.usefixtures("instrument_no_content") + def test_chat_with_optional_params(self, span_exporter): + client = _make_client() + client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello"}], + temperature=0.7, + max_tokens=100, + p=0.9, + frequency_penalty=0.5, + presence_penalty=0.3, + seed=42, + stop_sequences=["END"], + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = dict(spans[0].attributes) + assert attrs[GenAIAttributes.GEN_AI_REQUEST_TEMPERATURE] == 0.7 + assert attrs[GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS] == 100 + assert attrs[GenAIAttributes.GEN_AI_REQUEST_TOP_P] == 0.9 + assert attrs[GenAIAttributes.GEN_AI_REQUEST_FREQUENCY_PENALTY] == 0.5 + assert attrs[GenAIAttributes.GEN_AI_REQUEST_PRESENCE_PENALTY] == 0.3 + assert attrs[GenAIAttributes.GEN_AI_REQUEST_SEED] == 42 + assert attrs[GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES] == ("END",) + + @pytest.mark.usefixtures("instrument_no_content") + def test_chat_max_tokens_finish(self, span_exporter): + client = _make_client( + response_json=_chat_response_json(finish_reason="MAX_TOKENS") + ) + client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + + spans = span_exporter.get_finished_spans() + attrs = dict(spans[0].attributes) + assert attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ("length",) + + @pytest.mark.usefixtures("instrument_no_content") + def test_chat_error(self, span_exporter): + def error_handler(request): + raise httpx.ConnectError("Connection refused") + + client = _make_client(handler=error_handler) + with pytest.raises(Exception): + client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + span = spans[0] + assert span.status.status_code.name == "ERROR" + + +class TestChatCompletionsWithContent: + """Test sync chat completions with content capture enabled.""" + + @pytest.mark.usefixtures("instrument_with_content") + def test_chat_captures_content(self, span_exporter): + client = _make_client( + response_json=_chat_response_json( + content_text="I'm doing great, thanks!" + ) + ) + client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "How are you?"}], + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = dict(spans[0].attributes) + + assert GenAIAttributes.GEN_AI_INPUT_MESSAGES in attrs + assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES in attrs + + input_msgs = attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES] + assert "How are you?" in input_msgs + + output_msgs = attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES] + assert "I'm doing great, thanks!" in output_msgs + + @pytest.mark.usefixtures("instrument_with_content") + def test_chat_multi_message(self, span_exporter): + client = _make_client( + response_json=_chat_response_json( + content_text="The capital of France is Paris." + ) + ) + client.chat( + model="command-r-plus", + messages=[ + {"role": "system", "content": "You are a geography expert."}, + {"role": "user", "content": "What is the capital of France?"}, + ], + ) + + spans = span_exporter.get_finished_spans() + assert len(spans) == 1 + attrs = dict(spans[0].attributes) + input_msgs = attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES] + assert "geography expert" in input_msgs + assert "capital of France" in input_msgs + + + +class TestUninstrument: + """Test that uninstrumenting properly restores original methods.""" + + def test_uninstrument( + self, tracer_provider, logger_provider, meter_provider, span_exporter + ): + import os + + from opentelemetry.instrumentation._semconv import ( + OTEL_SEMCONV_STABILITY_OPT_IN, + _OpenTelemetrySemanticConventionStability, + ) + + _OpenTelemetrySemanticConventionStability._initialized = False + os.environ[OTEL_SEMCONV_STABILITY_OPT_IN] = "gen_ai_latest_experimental" + + instrumentor = CohereInstrumentor() + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + + client = _make_client() + client.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello"}], + ) + assert len(span_exporter.get_finished_spans()) == 1 + + instrumentor.uninstrument() + span_exporter.clear() + + client2 = _make_client() + client2.chat( + model="command-r-plus", + messages=[{"role": "user", "content": "Hello again"}], + ) + # After uninstrument, no new spans should be created + assert len(span_exporter.get_finished_spans()) == 0 + + os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py new file mode 100644 index 0000000000..baaf7e5119 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py @@ -0,0 +1,70 @@ +# 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. + +"""Tests for Cohere instrumentation utility functions.""" + +from types import SimpleNamespace + +import pytest + +from opentelemetry.instrumentation.cohere.utils import ( + get_server_address_and_port, + map_finish_reason, +) + + +class TestMapFinishReason: + def test_complete(self): + assert map_finish_reason("COMPLETE") == "stop" + + def test_stop_sequence(self): + assert map_finish_reason("STOP_SEQUENCE") == "stop" + + def test_max_tokens(self): + assert map_finish_reason("MAX_TOKENS") == "length" + + def test_tool_call(self): + assert map_finish_reason("TOOL_CALL") == "tool_calls" + + def test_error(self): + assert map_finish_reason("ERROR") == "error" + + def test_timeout(self): + assert map_finish_reason("TIMEOUT") == "error" + + def test_none(self): + assert map_finish_reason(None) == "error" + + def test_unknown(self): + assert map_finish_reason("UNKNOWN_REASON") == "unknown_reason" + + +class TestGetServerAddressAndPort: + def test_default_address(self): + client = SimpleNamespace() + address, port = get_server_address_and_port(client) + assert address == "api.cohere.com" + assert port is None + + def test_custom_base_url(self): + client = SimpleNamespace(base_url="https://custom.cohere.example.com:8443/v2") + address, port = get_server_address_and_port(client) + assert address == "custom.cohere.example.com" + assert port == 8443 + + def test_standard_https_port_omitted(self): + client = SimpleNamespace(base_url="https://api.cohere.com:443/v2") + address, port = get_server_address_and_port(client) + assert address == "api.cohere.com" + assert port is None From 3714ab7460b90aa7ef7bae65b11802167e680957 Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Tue, 14 Apr 2026 21:32:13 -0700 Subject: [PATCH 2/7] feat(cohere): Add full package scaffolding and CI integration Add base folder structure following the pattern established by the Anthropic instrumentation (PR #4179), as requested by @eternalcuriouslearner: - CHANGELOG.md with initial entry - Examples: manual and zero-code instrumentation examples - Test requirements: oldest and latest dependency matrices - CI integration: lint job, test matrix (Python 3.9-3.14), release workflows - tox.ini: test and lint environment configurations - component_owners.yml: Nik-Reddy as Cohere instrumentation maintainer - README updates: instrumentation-genai table, pyproject.toml pyright config - RELEASING.md: add Cohere to independently released packages list - Bootstrap script: exclude from default auto-instrumentation (early dev) --- .github/component_owners.yml | 3 + .github/workflows/lint.yml | 19 ++ .../package-prepare-patch-release.yml | 1 + .github/workflows/package-prepare-release.yml | 1 + .github/workflows/package-release.yml | 1 + .github/workflows/test.yml | 228 ++++++++++++++++++ RELEASING.md | 2 + instrumentation-genai/README.md | 1 + .../CHANGELOG.md | 18 ++ .../README.rst | 23 ++ .../examples/manual/.env | 2 + .../examples/manual/README.rst | 50 ++++ .../examples/manual/main.py | 46 ++++ .../examples/manual/requirements.txt | 4 + .../examples/zero-code/.env | 2 + .../examples/zero-code/README.rst | 55 +++++ .../examples/zero-code/main.py | 17 ++ .../examples/zero-code/requirements.txt | 5 + .../pyproject.toml | 2 +- .../tests/requirements.latest.txt | 47 ++++ .../tests/requirements.oldest.txt | 27 +++ pyproject.toml | 3 + scripts/generate_instrumentation_bootstrap.py | 4 + tox.ini | 13 + 24 files changed, 573 insertions(+), 1 deletion(-) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/.env create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/requirements.txt create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/.env create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/requirements.txt create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.latest.txt create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.oldest.txt diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 25a0a06e4b..37eb0418b3 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -60,3 +60,6 @@ components: - vasantteja - anirudha - MikeGoldsmith + + instrumentation-genai/opentelemetry-instrumentation-cohere: + - Nik-Reddy diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 6bf3c631cd..acd91e46d6 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -119,6 +119,25 @@ jobs: - name: Run tests run: tox -e lint-instrumentation-anthropic + lint-instrumentation-cohere: + name: instrumentation-cohere + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e lint-instrumentation-cohere + lint-instrumentation-claude-agent-sdk: name: instrumentation-claude-agent-sdk runs-on: ubuntu-latest diff --git a/.github/workflows/package-prepare-patch-release.yml b/.github/workflows/package-prepare-patch-release.yml index 5ad5615978..67a4a1336c 100644 --- a/.github/workflows/package-prepare-patch-release.yml +++ b/.github/workflows/package-prepare-patch-release.yml @@ -13,6 +13,7 @@ on: - opentelemetry-instrumentation-openai-agents-v2 - opentelemetry-instrumentation-vertexai - opentelemetry-instrumentation-anthropic + - opentelemetry-instrumentation-cohere - opentelemetry-instrumentation-claude-agent-sdk - opentelemetry-instrumentation-google-genai - opentelemetry-util-genai diff --git a/.github/workflows/package-prepare-release.yml b/.github/workflows/package-prepare-release.yml index f9819d3668..c578d4f117 100644 --- a/.github/workflows/package-prepare-release.yml +++ b/.github/workflows/package-prepare-release.yml @@ -13,6 +13,7 @@ on: - opentelemetry-instrumentation-openai-agents-v2 - opentelemetry-instrumentation-vertexai - opentelemetry-instrumentation-anthropic + - opentelemetry-instrumentation-cohere - opentelemetry-instrumentation-claude-agent-sdk - opentelemetry-instrumentation-google-genai - opentelemetry-util-genai diff --git a/.github/workflows/package-release.yml b/.github/workflows/package-release.yml index 3ab6341b55..9c45094497 100644 --- a/.github/workflows/package-release.yml +++ b/.github/workflows/package-release.yml @@ -13,6 +13,7 @@ on: - opentelemetry-instrumentation-openai-agents-v2 - opentelemetry-instrumentation-vertexai - opentelemetry-instrumentation-anthropic + - opentelemetry-instrumentation-cohere - opentelemetry-instrumentation-claude-agent-sdk - opentelemetry-instrumentation-google-genai - opentelemetry-util-genai diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9c694007ea..c1fb967aa7 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1031,6 +1031,234 @@ jobs: - name: Run tests run: tox -e py314-test-instrumentation-anthropic-latest -- -ra + py39-test-instrumentation-cohere-oldest_ubuntu-latest: + name: instrumentation-cohere-oldest 3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py39-test-instrumentation-cohere-oldest -- -ra + + py39-test-instrumentation-cohere-latest_ubuntu-latest: + name: instrumentation-cohere-latest 3.9 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.9 + uses: actions/setup-python@v5 + with: + python-version: "3.9" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py39-test-instrumentation-cohere-latest -- -ra + + py310-test-instrumentation-cohere-oldest_ubuntu-latest: + name: instrumentation-cohere-oldest 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-cohere-oldest -- -ra + + py310-test-instrumentation-cohere-latest_ubuntu-latest: + name: instrumentation-cohere-latest 3.10 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.10 + uses: actions/setup-python@v5 + with: + python-version: "3.10" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py310-test-instrumentation-cohere-latest -- -ra + + py311-test-instrumentation-cohere-oldest_ubuntu-latest: + name: instrumentation-cohere-oldest 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-cohere-oldest -- -ra + + py311-test-instrumentation-cohere-latest_ubuntu-latest: + name: instrumentation-cohere-latest 3.11 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py311-test-instrumentation-cohere-latest -- -ra + + py312-test-instrumentation-cohere-oldest_ubuntu-latest: + name: instrumentation-cohere-oldest 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-cohere-oldest -- -ra + + py312-test-instrumentation-cohere-latest_ubuntu-latest: + name: instrumentation-cohere-latest 3.12 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.12 + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py312-test-instrumentation-cohere-latest -- -ra + + py313-test-instrumentation-cohere-oldest_ubuntu-latest: + name: instrumentation-cohere-oldest 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-cohere-oldest -- -ra + + py313-test-instrumentation-cohere-latest_ubuntu-latest: + name: instrumentation-cohere-latest 3.13 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.13 + uses: actions/setup-python@v5 + with: + python-version: "3.13" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py313-test-instrumentation-cohere-latest -- -ra + + py314-test-instrumentation-cohere-oldest_ubuntu-latest: + name: instrumentation-cohere-oldest 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-cohere-oldest -- -ra + + py314-test-instrumentation-cohere-latest_ubuntu-latest: + name: instrumentation-cohere-latest 3.14 Ubuntu + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout repo @ SHA - ${{ github.sha }} + uses: actions/checkout@v4 + + - name: Set up Python 3.14 + uses: actions/setup-python@v5 + with: + python-version: "3.14" + + - name: Install tox + run: pip install tox-uv + + - name: Run tests + run: tox -e py314-test-instrumentation-cohere-latest -- -ra + py310-test-instrumentation-claude-agent-sdk-oldest_ubuntu-latest: name: instrumentation-claude-agent-sdk-oldest 3.10 Ubuntu runs-on: ubuntu-latest diff --git a/RELEASING.md b/RELEASING.md index b5dfb41922..3710f9de15 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -26,6 +26,7 @@ > - opentelemetry-instrumentation-openai-agents-v2 > - opentelemetry-instrumentation-vertexai > - opentelemetry-instrumentation-anthropic +> - opentelemetry-instrumentation-cohere > - opentelemetry-instrumentation-claude-agent-sdk > - opentelemetry-instrumentation-google-genai > - opentelemetry-instrumentation-langchain @@ -107,6 +108,7 @@ The workflow will create a pull request that should be merged in order to procee > - opentelemetry-instrumentation-openai-agents-v2 > - opentelemetry-instrumentation-vertexai > - opentelemetry-instrumentation-anthropic +> - opentelemetry-instrumentation-cohere > - opentelemetry-instrumentation-claude-agent-sdk > - opentelemetry-instrumentation-google-genai > - opentelemetry-instrumentation-langchain diff --git a/instrumentation-genai/README.md b/instrumentation-genai/README.md index dbe51b523e..78fa4bd58d 100644 --- a/instrumentation-genai/README.md +++ b/instrumentation-genai/README.md @@ -3,6 +3,7 @@ | --------------- | ------------------ | --------------- | -------------- | | [opentelemetry-instrumentation-anthropic](./opentelemetry-instrumentation-anthropic) | anthropic >= 0.16.0 | No | development | [opentelemetry-instrumentation-claude-agent-sdk](./opentelemetry-instrumentation-claude-agent-sdk) | claude-agent-sdk >= 0.1.14 | No | development +| [opentelemetry-instrumentation-cohere](./opentelemetry-instrumentation-cohere) | cohere >= 5.0.0 | No | development | [opentelemetry-instrumentation-google-genai](./opentelemetry-instrumentation-google-genai) | google-genai >= 1.32.0 | No | development | [opentelemetry-instrumentation-langchain](./opentelemetry-instrumentation-langchain) | langchain >= 0.3.21 | No | development | [opentelemetry-instrumentation-openai-agents-v2](./opentelemetry-instrumentation-openai-agents-v2) | openai-agents >= 0.3.3 | No | development diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md new file mode 100644 index 0000000000..79aeb373ed --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md @@ -0,0 +1,18 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## Unreleased + +### Added + +- Initial implementation of Cohere instrumentation + ([#4418](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4418)) +- Implement sync and async `V2Client.chat` instrumentation with GenAI semantic convention attributes + - Captures request attributes: `gen_ai.request.model`, `gen_ai.request.max_tokens`, `gen_ai.request.temperature`, `gen_ai.request.top_p`, `gen_ai.request.frequency_penalty`, `gen_ai.request.presence_penalty`, `gen_ai.request.seed`, `gen_ai.request.stop_sequences` + - Captures response attributes: `gen_ai.response.id`, `gen_ai.response.model`, `gen_ai.response.finish_reasons`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` + - Error handling with `error.type` attribute + - Minimum supported cohere version is 5.0.0 diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst index 2ae12a795d..2e218885d5 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst @@ -15,9 +15,17 @@ Installation pip install opentelemetry-instrumentation-cohere +If you don't have a Cohere application yet, try our `examples `_ +which only need a valid Cohere API key. + +Check out the `zero-code example `_ for a quick start. + Usage ----- +This section describes how to set up Cohere instrumentation if you're setting OpenTelemetry up manually. +Check out the `manual example `_ for more details. + .. code-block:: python from cohere import ClientV2 @@ -33,6 +41,21 @@ Usage ], ) + +Configuration +------------- + +Capture Message Content +*********************** + +By default, prompts and completions are not captured. To enable message content capture, +set the environment variable: + +:: + + export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true + + References ---------- diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/.env b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/.env new file mode 100644 index 0000000000..61e915052a --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/.env @@ -0,0 +1,2 @@ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst new file mode 100644 index 0000000000..54853c4cee --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst @@ -0,0 +1,50 @@ +OpenTelemetry Cohere Instrumentation Example +============================================= + +This is an example of how to instrument Cohere calls when configuring +OpenTelemetry SDK and Instrumentations manually. + +When `main.py `_ is run, it exports traces and logs to an OTLP +compatible endpoint. Traces include details such as the model used and the +duration of the chat request. Logs capture the chat request and the generated +response, providing a comprehensive view of the performance and behavior of +your Cohere requests. + +Note: `.env <.env>`_ file configures additional environment variables: + +- `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` configures + Cohere instrumentation to capture prompt and completion contents on + events. + +Setup +----- + +An OTLP compatible endpoint should be listening for traces and logs on +http://localhost:4317. If not, update "OTEL_EXPORTER_OTLP_ENDPOINT" as well. + +Next, set up a virtual environment like this: + +:: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + +You will also need a Cohere API key. Set it as an environment variable: + +:: + + export CO_API_KEY=your_api_key_here + +Run +--- + +Run the example like this: + +:: + + dotenv run -- python main.py + +You should see a poem generated by Cohere while traces and logs export to your +configured observability tool. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py new file mode 100644 index 0000000000..db07358f75 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py @@ -0,0 +1,46 @@ +# pylint: skip-file +import cohere + +# NOTE: OpenTelemetry Python Logs and Events APIs are in beta +from opentelemetry import _logs, trace +from opentelemetry.exporter.otlp.proto.grpc._log_exporter import ( + OTLPLogExporter, +) +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import ( + OTLPSpanExporter, +) +from opentelemetry.instrumentation.cohere import CohereInstrumentor +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import BatchLogRecordProcessor +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import BatchSpanProcessor + +# configure tracing +trace.set_tracer_provider(TracerProvider()) +trace.get_tracer_provider().add_span_processor( + BatchSpanProcessor(OTLPSpanExporter()) +) + +# configure logging and events +_logs.set_logger_provider(LoggerProvider()) +_logs.get_logger_provider().add_log_record_processor( + BatchLogRecordProcessor(OTLPLogExporter()) +) + +# instrument Cohere +CohereInstrumentor().instrument() + + +def main(): + client = cohere.ClientV2() + response = client.chat( + model="command-r-plus", + messages=[ + {"role": "user", "content": "Write a short poem on OpenTelemetry."} + ], + ) + print(response.message.content[0].text) + + +if __name__ == "__main__": + main() diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/requirements.txt new file mode 100644 index 0000000000..38079afd3f --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/requirements.txt @@ -0,0 +1,4 @@ +cohere>=5.0.0 +opentelemetry-sdk~=1.39.0 +opentelemetry-instrumentation-cohere +opentelemetry-exporter-otlp-proto-grpc~=1.39.0 diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/.env b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/.env new file mode 100644 index 0000000000..61e915052a --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/.env @@ -0,0 +1,2 @@ +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317 +OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst new file mode 100644 index 0000000000..0dcc653a51 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst @@ -0,0 +1,55 @@ +OpenTelemetry Cohere Zero-Code Instrumentation Example +====================================================== + +This is an example of how to use OpenTelemetry's automatic instrumentation +(zero-code) capabilities with the Cohere SDK. + +The `opentelemetry-instrument` CLI automatically instruments your Python +application without requiring code changes. When `main.py `_ is run +with the CLI, it exports traces and logs to an OTLP compatible endpoint. + +Note: `.env <.env>`_ file configures additional environment variables: + +- `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` configures + Cohere instrumentation to capture prompt and completion contents on + events. + +Setup +----- + +An OTLP compatible endpoint should be listening for traces and logs on +http://localhost:4317. If not, update "OTEL_EXPORTER_OTLP_ENDPOINT" as well. + +Next, set up a virtual environment like this: + +:: + + python3 -m venv .venv + source .venv/bin/activate + pip install "python-dotenv[cli]" + pip install -r requirements.txt + +You will also need a Cohere API key. Set it as an environment variable: + +:: + + export CO_API_KEY=your_api_key_here + +Run +--- + +Run the example with zero-code instrumentation like this: + +:: + + dotenv run -- opentelemetry-instrument python main.py + +You should see a poem generated by Cohere while traces and logs export to your +configured observability tool. No changes to `main.py` were required! + +Learn More +---------- + +See the `OpenTelemetry Python automatic instrumentation docs +`_ for more +information about zero-code instrumentation. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py new file mode 100644 index 0000000000..4ed36941d8 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py @@ -0,0 +1,17 @@ +# pylint: skip-file +import cohere + + +def main(): + client = cohere.ClientV2() + response = client.chat( + model="command-r-plus", + messages=[ + {"role": "user", "content": "Write a short poem on OpenTelemetry."} + ], + ) + print(response.message.content[0].text) + + +if __name__ == "__main__": + main() diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/requirements.txt b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/requirements.txt new file mode 100644 index 0000000000..0c34e2c868 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/requirements.txt @@ -0,0 +1,5 @@ +cohere>=5.0.0 +opentelemetry-sdk~=1.39.0 +opentelemetry-distro~=0.60b0 +opentelemetry-instrumentation-cohere +opentelemetry-exporter-otlp-proto-grpc~=1.39.0 diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml index 09aa4ed3dd..6cddf0eff3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml @@ -52,7 +52,7 @@ Repository = "https://github.com/open-telemetry/opentelemetry-python-contrib" path = "src/opentelemetry/instrumentation/cohere/version.py" [tool.hatch.build.targets.sdist] -include = ["/src", "/tests"] +include = ["/src", "/tests", "/examples"] [tool.hatch.build.targets.wheel] packages = ["src/opentelemetry"] diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.latest.txt b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.latest.txt new file mode 100644 index 0000000000..5246fe8ee6 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.latest.txt @@ -0,0 +1,47 @@ +# 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. + + +# ******************************** +# WARNING: NOT HERMETIC !!!!!!!!!! +# ******************************** +# +# This "requirements.txt" is installed in conjunction +# with multiple other dependencies in the top-level "tox.ini" +# file. In particular, please see: +# +# cohere-latest: {[testenv]test_deps} +# cohere-latest: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.latest.txt +# +# This provides additional dependencies, namely: +# +# opentelemetry-api +# opentelemetry-sdk +# opentelemetry-semantic-conventions +# +# ... with a "dev" version based on the latest distribution. + + +# This variant of the requirements aims to test the system using +# the newest supported version of external dependencies. + +cohere +pytest==7.4.4 +pytest-asyncio==0.21.0 +wrapt==1.16.0 +# test with the latest version of opentelemetry-api, sdk, and semantic conventions + +-e opentelemetry-instrumentation +-e util/opentelemetry-util-genai +-e instrumentation-genai/opentelemetry-instrumentation-cohere diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.oldest.txt b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.oldest.txt new file mode 100644 index 0000000000..031dfd4493 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.oldest.txt @@ -0,0 +1,27 @@ +# 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 variant of the requirements aims to test the system using +# the oldest supported version of external dependencies. + +-e util/opentelemetry-util-genai +cohere==5.0.0 +pytest==7.4.4 +pytest-asyncio==0.21.0 +wrapt==1.16.0 +opentelemetry-api==1.39 # when updating, also update in pyproject.toml +opentelemetry-sdk==1.39 # when updating, also update in pyproject.toml +opentelemetry-semantic-conventions==0.60b0 # when updating, also update in pyproject.toml + +-e instrumentation-genai/opentelemetry-instrumentation-cohere diff --git a/pyproject.toml b/pyproject.toml index 17fb43b107..119c7dc955 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -207,6 +207,7 @@ include = [ "instrumentation/opentelemetry-instrumentation-threading", "instrumentation-genai/opentelemetry-instrumentation-anthropic", "instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk", + "instrumentation-genai/opentelemetry-instrumentation-cohere", "instrumentation-genai/opentelemetry-instrumentation-vertexai", "instrumentation-genai/opentelemetry-instrumentation-langchain", "instrumentation-genai/opentelemetry-instrumentation-weaviate", @@ -225,6 +226,8 @@ exclude = [ "instrumentation-genai/opentelemetry-instrumentation-anthropic/examples/**/*.py", "instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/**/*.py", "instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/examples/**/*.py", + "instrumentation-genai/opentelemetry-instrumentation-cohere/tests/**/*.py", + "instrumentation-genai/opentelemetry-instrumentation-cohere/examples/**/*.py", "instrumentation-genai/opentelemetry-instrumentation-vertexai/tests/**/*.py", "instrumentation-genai/opentelemetry-instrumentation-vertexai/examples/**/*.py", "instrumentation-genai/opentelemetry-instrumentation-langchain/tests/**/*.py", diff --git a/scripts/generate_instrumentation_bootstrap.py b/scripts/generate_instrumentation_bootstrap.py index dd40e0223f..53ba567bb7 100755 --- a/scripts/generate_instrumentation_bootstrap.py +++ b/scripts/generate_instrumentation_bootstrap.py @@ -84,6 +84,10 @@ # development. This filter will get removed once it is further along in its # development lifecycle and ready to be included by default. "opentelemetry-instrumentation-claude-agent-sdk", + # Cohere instrumentation is currently excluded because it is still in early + # development. This filter will get removed once it is further along in its + # development lifecycle and ready to be included by default. + "opentelemetry-instrumentation-cohere", ] # Static version specifiers for instrumentations that are released independently diff --git a/tox.ini b/tox.ini index 1f2ca7f8e9..dde15d65d8 100644 --- a/tox.ini +++ b/tox.ini @@ -36,6 +36,10 @@ envlist = # pypy3-test-instrumentation-anthropic-{oldest,latest} lint-instrumentation-anthropic + ; instrumentation-cohere + py3{9,10,11,12,13,14}-test-instrumentation-cohere-{oldest,latest} + lint-instrumentation-cohere + ; instrumentation-claude-agent-sdk py3{10,11,12,13}-test-instrumentation-claude-agent-sdk-{oldest,latest} # Disabling pypy3 as jiter (anthropic dep) requires PyPy 3.11+ @@ -502,6 +506,11 @@ deps = anthropic-latest: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.latest.txt lint-instrumentation-anthropic: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests/requirements.oldest.txt + cohere-oldest: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.oldest.txt + cohere-latest: {[testenv]test_deps} + cohere-latest: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.latest.txt + lint-instrumentation-cohere: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/requirements.oldest.txt + claude-agent-sdk-oldest: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.oldest.txt claude-agent-sdk-latest: {[testenv]test_deps} claude-agent-sdk-latest: -r {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests/requirements.latest.txt @@ -917,6 +926,9 @@ commands = test-instrumentation-anthropic: pytest {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-anthropic/tests --vcr-record=none {posargs} lint-instrumentation-anthropic: sh -c "cd instrumentation-genai && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-anthropic" + test-instrumentation-cohere: pytest {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-cohere/tests {posargs} + lint-instrumentation-cohere: sh -c "cd instrumentation-genai && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-cohere" + test-instrumentation-claude-agent-sdk: pytest {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk/tests --vcr-record=none {posargs} lint-instrumentation-claude-agent-sdk: sh -c "cd instrumentation-genai && pylint --rcfile ../.pylintrc opentelemetry-instrumentation-claude-agent-sdk" @@ -1144,6 +1156,7 @@ deps = {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-vertexai[instruments] {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-google-genai[instruments] {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-anthropic[instruments] + {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-cohere[instruments] {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-langchain[instruments] {toxinidir}/instrumentation-genai/opentelemetry-instrumentation-claude-agent-sdk[instruments] {toxinidir}/instrumentation/opentelemetry-instrumentation-aiokafka[instruments] From 77fc4269fbcc43f23463bcc65a47e25a2bcff502 Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Wed, 15 Apr 2026 17:06:49 -0700 Subject: [PATCH 3/7] refactor(cohere): strip chat completions, keep scaffolding only Per reviewer feedback, this PR now contains only the base folder scaffolding and CI integration for the Cohere instrumentation package. Chat completions implementation will be added in a follow-up PR. --- .../examples/manual/main.py | 11 +- .../examples/zero-code/main.py | 11 +- .../instrumentation/cohere/__init__.py | 54 +--- .../instrumentation/cohere/patch.py | 63 +---- .../instrumentation/cohere/utils.py | 216 +-------------- .../tests/conftest.py | 133 --------- .../tests/test_async_chat_completions.py | 137 ---------- .../tests/test_chat_completions.py | 252 ------------------ .../tests/test_utils.py | 70 ----- 9 files changed, 12 insertions(+), 935 deletions(-) delete mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py delete mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py delete mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py delete mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py index db07358f75..31dc79c408 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/main.py @@ -1,5 +1,4 @@ # pylint: skip-file -import cohere # NOTE: OpenTelemetry Python Logs and Events APIs are in beta from opentelemetry import _logs, trace @@ -32,14 +31,8 @@ def main(): - client = cohere.ClientV2() - response = client.chat( - model="command-r-plus", - messages=[ - {"role": "user", "content": "Write a short poem on OpenTelemetry."} - ], - ) - print(response.message.content[0].text) + # TODO: Chat completions example will be added in a follow-up PR. + print("Cohere instrumentation is active.") if __name__ == "__main__": diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py index 4ed36941d8..ef23a5636c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/main.py @@ -1,16 +1,9 @@ # pylint: skip-file -import cohere def main(): - client = cohere.ClientV2() - response = client.chat( - model="command-r-plus", - messages=[ - {"role": "user", "content": "Write a short poem on OpenTelemetry."} - ], - ) - print(response.message.content[0].text) + # TODO: Chat completions example will be added in a follow-up PR. + print("Cohere instrumentation is active (zero-code).") if __name__ == "__main__": diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py index 227cdeb0da..dafcee470c 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py @@ -42,22 +42,8 @@ from typing import Collection -from wrapt import wrap_function_wrapper - from opentelemetry.instrumentation.cohere.package import _instruments from opentelemetry.instrumentation.instrumentor import BaseInstrumentor -from opentelemetry.instrumentation.utils import unwrap -from opentelemetry.util.genai.handler import TelemetryHandler -from opentelemetry.util.genai.types import ContentCapturingMode -from opentelemetry.util.genai.utils import ( - get_content_capturing_mode, - is_experimental_mode, -) - -from .patch import ( - async_chat_create, - chat_create, -) class CohereInstrumentor(BaseInstrumentor): @@ -65,41 +51,13 @@ def instrumentation_dependencies(self) -> Collection[str]: return _instruments def _instrument(self, **kwargs): - """Enable Cohere instrumentation.""" - tracer_provider = kwargs.get("tracer_provider") - meter_provider = kwargs.get("meter_provider") - logger_provider = kwargs.get("logger_provider") - - latest_experimental_enabled = is_experimental_mode() - content_mode = ( - get_content_capturing_mode() - if latest_experimental_enabled - else ContentCapturingMode.NO_CONTENT - ) - - handler = TelemetryHandler( - tracer_provider=tracer_provider, - meter_provider=meter_provider, - logger_provider=logger_provider, - ) - - # Instrument sync V2Client.chat - wrap_function_wrapper( - module="cohere.v2.client", - name="V2Client.chat", - wrapper=chat_create(handler, content_mode), - ) - - # Instrument async AsyncV2Client.chat - wrap_function_wrapper( - module="cohere.v2.client", - name="AsyncV2Client.chat", - wrapper=async_chat_create(handler, content_mode), - ) + """Enable Cohere instrumentation. + TODO: Chat completions patching will be added in a follow-up PR. + """ def _uninstrument(self, **kwargs): - import cohere.v2.client # pylint: disable=import-outside-toplevel + """Disable Cohere instrumentation. - unwrap(cohere.v2.client.V2Client, "chat") - unwrap(cohere.v2.client.AsyncV2Client, "chat") + TODO: Chat completions unpatching will be added in a follow-up PR. + """ diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py index 0d1077bdf1..daed4028ff 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/patch.py @@ -12,65 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from opentelemetry.util.genai.handler import TelemetryHandler -from opentelemetry.util.genai.types import ( - ContentCapturingMode, - Error, -) - -from .utils import ( - create_chat_invocation, - set_response_attributes, -) - - -def chat_create( - handler: TelemetryHandler, - content_capturing_mode: ContentCapturingMode, -): - """Wrap ``V2Client.chat`` to emit GenAI telemetry.""" - capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT - - def traced_method(wrapped, instance, args, kwargs): - invocation = handler.start_llm( - create_chat_invocation(kwargs, instance, capture_content=capture_content) - ) - try: - result = wrapped(*args, **kwargs) - set_response_attributes(invocation, result, capture_content) - handler.stop_llm(invocation) - return result - except Exception as error: - handler.fail_llm( - invocation, Error(type=type(error), message=str(error)) - ) - raise - - return traced_method - - -def async_chat_create( - handler: TelemetryHandler, - content_capturing_mode: ContentCapturingMode, -): - """Wrap ``AsyncV2Client.chat`` to emit GenAI telemetry.""" - capture_content = content_capturing_mode != ContentCapturingMode.NO_CONTENT - - async def traced_method(wrapped, instance, args, kwargs): - invocation = handler.start_llm( - create_chat_invocation(kwargs, instance, capture_content=capture_content) - ) - try: - result = await wrapped(*args, **kwargs) - set_response_attributes(invocation, result, capture_content) - handler.stop_llm(invocation) - return result - except Exception as error: - handler.fail_llm( - invocation, Error(type=type(error), message=str(error)) - ) - raise - - return traced_method +# TODO: Chat completions patching will be added in a follow-up PR. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py index 0655bbc37a..b2d1f6b9bf 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py @@ -12,218 +12,4 @@ # See the License for the specific language governing permissions and # limitations under the License. -from __future__ import annotations - -from typing import Any, List, Optional - -from opentelemetry.util.genai.types import ( - InputMessage, - LLMInvocation, - OutputMessage, - Text, - ToolCallRequest, -) - -COHERE_PROVIDER_NAME = "cohere" - -# Mapping from Cohere finish reasons to GenAI semantic convention finish reasons -_FINISH_REASON_MAP = { - "COMPLETE": "stop", - "STOP_SEQUENCE": "stop", - "MAX_TOKENS": "length", - "TOOL_CALL": "tool_calls", - "ERROR": "error", - "TIMEOUT": "error", -} - - -def map_finish_reason(cohere_reason: Optional[str]) -> str: - if cohere_reason is None: - return "error" - return _FINISH_REASON_MAP.get(str(cohere_reason), str(cohere_reason).lower()) - - -def get_server_address_and_port( - client_instance: Any, -) -> tuple[Optional[str], Optional[int]]: - """Extract server address and port from the Cohere client instance.""" - base_url = getattr(client_instance, "base_url", None) - if base_url is None: - # Check nested _client pattern - inner = getattr(client_instance, "_client", None) - if inner is not None: - base_url = getattr(inner, "base_url", None) - - if base_url is None: - return "api.cohere.com", None - - if isinstance(base_url, str): - from urllib.parse import urlparse - - parsed = urlparse(base_url) - address = parsed.hostname or "api.cohere.com" - port = parsed.port - if port == 443: - port = None - return address, port - - return "api.cohere.com", None - - -def create_chat_invocation( - kwargs: dict[str, Any], - client_instance: Any, - capture_content: bool, -) -> LLMInvocation: - """Create an LLMInvocation from Cohere chat kwargs.""" - invocation = LLMInvocation(request_model=kwargs.get("model", "")) - invocation.provider = COHERE_PROVIDER_NAME - invocation.temperature = kwargs.get("temperature") - invocation.top_p = kwargs.get("p") - invocation.max_tokens = kwargs.get("max_tokens") - invocation.frequency_penalty = kwargs.get("frequency_penalty") - invocation.presence_penalty = kwargs.get("presence_penalty") - invocation.seed = kwargs.get("seed") - - stop_sequences = kwargs.get("stop_sequences") - if stop_sequences is not None: - if isinstance(stop_sequences, str): - stop_sequences = [stop_sequences] - invocation.stop_sequences = list(stop_sequences) - - address, port = get_server_address_and_port(client_instance) - if address: - invocation.server_address = address - if port: - invocation.server_port = port - - if capture_content: - invocation.input_messages = _extract_input_messages( - kwargs.get("messages", []) - ) - - return invocation - - -def set_response_attributes( - invocation: LLMInvocation, - response: Any, - capture_content: bool, -) -> None: - """Populate invocation attributes from a Cohere V2ChatResponse.""" - if response is None: - return - - response_id = getattr(response, "id", None) - if response_id: - invocation.response_id = response_id - - finish_reason = getattr(response, "finish_reason", None) - if finish_reason is not None: - invocation.finish_reasons = [map_finish_reason(finish_reason)] - - usage = getattr(response, "usage", None) - if usage is not None: - _set_usage(invocation, usage) - - if capture_content: - message = getattr(response, "message", None) - if message is not None: - invocation.output_messages = _extract_output_messages( - message, finish_reason - ) - - -def _set_usage(invocation: LLMInvocation, usage: Any) -> None: - """Extract token usage from Cohere Usage object.""" - tokens = getattr(usage, "tokens", None) - if tokens is not None: - input_tokens = getattr(tokens, "input_tokens", None) - if input_tokens is not None: - invocation.input_tokens = int(input_tokens) - output_tokens = getattr(tokens, "output_tokens", None) - if output_tokens is not None: - invocation.output_tokens = int(output_tokens) - return - - # Fallback to billed_units - billed = getattr(usage, "billed_units", None) - if billed is not None: - input_tokens = getattr(billed, "input_tokens", None) - if input_tokens is not None: - invocation.input_tokens = int(input_tokens) - output_tokens = getattr(billed, "output_tokens", None) - if output_tokens is not None: - invocation.output_tokens = int(output_tokens) - - -def _extract_input_messages( - messages: List[Any], -) -> List[InputMessage]: - """Convert Cohere chat messages to InputMessage list.""" - result = [] - for msg in messages: - if isinstance(msg, dict): - role = msg.get("role", "user") - content = msg.get("content", "") - else: - role = getattr(msg, "role", "user") - content = getattr(msg, "content", "") - - parts: list = [] - if isinstance(content, str): - parts.append(Text(content=content)) - elif isinstance(content, list): - # Handle structured content items - for item in content: - if isinstance(item, dict): - text = item.get("text", "") - if text: - parts.append(Text(content=text)) - elif isinstance(item, str): - parts.append(Text(content=item)) - else: - text = getattr(item, "text", None) - if text: - parts.append(Text(content=str(text))) - - result.append(InputMessage(role=str(role), parts=parts)) - return result - - -def _extract_output_messages( - message: Any, - finish_reason: Any, -) -> List[OutputMessage]: - """Convert a Cohere AssistantMessageResponse to OutputMessage list.""" - parts: list = [] - - content_items = getattr(message, "content", None) - if content_items: - for item in content_items: - item_type = getattr(item, "type", None) - if item_type == "text": - text = getattr(item, "text", "") - parts.append(Text(content=str(text))) - - tool_calls = getattr(message, "tool_calls", None) - if tool_calls: - for tc in tool_calls: - tc_id = getattr(tc, "id", None) - tc_name = "" - tc_args = None - func = getattr(tc, "function", None) - if func: - tc_name = getattr(func, "name", "") or "" - tc_args = getattr(func, "arguments", None) - else: - tc_name = getattr(tc, "name", "") or "" - tc_args = getattr(tc, "parameters", None) - parts.append( - ToolCallRequest(id=tc_id, name=tc_name, arguments=tc_args) - ) - - role = getattr(message, "role", "assistant") or "assistant" - mapped_reason = map_finish_reason(finish_reason) - - return [OutputMessage(role=str(role), parts=parts, finish_reason=mapped_reason)] +# TODO: Chat completions utility helpers will be added in a follow-up PR. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py deleted file mode 100644 index 2d9c084eaf..0000000000 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py +++ /dev/null @@ -1,133 +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. - -"""Test configuration for Cohere instrumentation tests.""" - -import os - -import pytest - -from opentelemetry.instrumentation._semconv import ( - OTEL_SEMCONV_STABILITY_OPT_IN, - _OpenTelemetrySemanticConventionStability, -) -from opentelemetry.instrumentation.cohere import CohereInstrumentor -from opentelemetry.sdk._logs import LoggerProvider -from opentelemetry.util.genai.environment_variables import ( - OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, -) - -try: - from opentelemetry.sdk._logs.export import ( - InMemoryLogRecordExporter, - SimpleLogRecordProcessor, - ) -except ImportError: - from opentelemetry.sdk._logs.export import ( - InMemoryLogExporter as InMemoryLogRecordExporter, - ) - from opentelemetry.sdk._logs.export import ( - SimpleLogRecordProcessor, - ) - -from opentelemetry.sdk.metrics import MeterProvider -from opentelemetry.sdk.metrics.export import InMemoryMetricReader -from opentelemetry.sdk.trace import TracerProvider -from opentelemetry.sdk.trace.export import SimpleSpanProcessor -from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, -) - - -@pytest.fixture(scope="function", name="span_exporter") -def fixture_span_exporter(): - exporter = InMemorySpanExporter() - yield exporter - - -@pytest.fixture(scope="function", name="log_exporter") -def fixture_log_exporter(): - exporter = InMemoryLogRecordExporter() - yield exporter - - -@pytest.fixture(scope="function", name="metric_reader") -def fixture_metric_reader(): - reader = InMemoryMetricReader() - yield reader - - -@pytest.fixture(scope="function", name="tracer_provider") -def fixture_tracer_provider(span_exporter): - provider = TracerProvider() - provider.add_span_processor(SimpleSpanProcessor(span_exporter)) - return provider - - -@pytest.fixture(scope="function", name="logger_provider") -def fixture_logger_provider(log_exporter): - provider = LoggerProvider() - provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter)) - return provider - - -@pytest.fixture(scope="function", name="meter_provider") -def fixture_meter_provider(metric_reader): - meter_provider = MeterProvider( - metric_readers=[metric_reader], - ) - return meter_provider - - -@pytest.fixture(autouse=True) -def environment(): - if not os.getenv("CO_API_KEY"): - os.environ["CO_API_KEY"] = "test_cohere_api_key" - - -@pytest.fixture(scope="function") -def instrument_no_content(tracer_provider, logger_provider, meter_provider): - _OpenTelemetrySemanticConventionStability._initialized = False - os.environ[OTEL_SEMCONV_STABILITY_OPT_IN] = "gen_ai_latest_experimental" - - instrumentor = CohereInstrumentor() - instrumentor.instrument( - tracer_provider=tracer_provider, - logger_provider=logger_provider, - meter_provider=meter_provider, - ) - - yield instrumentor - os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) - os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) - instrumentor.uninstrument() - - -@pytest.fixture(scope="function") -def instrument_with_content(tracer_provider, logger_provider, meter_provider): - _OpenTelemetrySemanticConventionStability._initialized = False - os.environ[OTEL_SEMCONV_STABILITY_OPT_IN] = "gen_ai_latest_experimental" - os.environ[OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT] = "span_only" - - instrumentor = CohereInstrumentor() - instrumentor.instrument( - tracer_provider=tracer_provider, - logger_provider=logger_provider, - meter_provider=meter_provider, - ) - - yield instrumentor - os.environ.pop(OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT, None) - os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) - instrumentor.uninstrument() diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py deleted file mode 100644 index cbf18c450c..0000000000 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_async_chat_completions.py +++ /dev/null @@ -1,137 +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. - -"""Tests for async Cohere chat completions instrumentation.""" - -import asyncio - -import httpx -import pytest - -from opentelemetry.semconv._incubating.attributes import ( - gen_ai_attributes as GenAIAttributes, -) - - -def _chat_response_json( - response_id="async-response-id", - finish_reason="COMPLETE", - content_text="Hello from async!", - input_tokens=10, - output_tokens=20, -): - return { - "id": response_id, - "finish_reason": finish_reason, - "message": { - "role": "assistant", - "content": [{"type": "text", "text": content_text}], - }, - "usage": { - "tokens": { - "input_tokens": input_tokens, - "output_tokens": output_tokens, - }, - }, - } - - -def _make_async_client(response_json=None, handler=None): - """Create a Cohere AsyncClientV2 with a mock HTTP transport.""" - if handler is None: - body = response_json or _chat_response_json() - - async def default_handler(request): - return httpx.Response(200, json=body) - - handler = default_handler - - transport = httpx.MockTransport(handler) - httpx_client = httpx.AsyncClient(transport=transport) - - from cohere import AsyncClientV2 - - return AsyncClientV2(api_key="test-key", httpx_client=httpx_client) - - -class TestAsyncChatCompletions: - """Test async chat completions.""" - - @pytest.mark.usefixtures("instrument_no_content") - def test_async_chat_basic(self, span_exporter): - async def run(): - client = _make_async_client() - response = await client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello async"}], - ) - return response - - response = asyncio.run(run()) - assert response.id == "async-response-id" - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - - span = spans[0] - assert span.name == "chat command-r-plus" - attrs = dict(span.attributes) - assert attrs[GenAIAttributes.GEN_AI_OPERATION_NAME] == "chat" - assert attrs[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "command-r-plus" - assert attrs[GenAIAttributes.GEN_AI_PROVIDER_NAME] == "cohere" - assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] == 10 - assert attrs[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 - assert attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ("stop",) - - @pytest.mark.usefixtures("instrument_no_content") - def test_async_chat_error(self, span_exporter): - async def error_handler(request): - raise httpx.ConnectError("Async connection refused") - - async def run(): - client = _make_async_client(handler=error_handler) - await client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello"}], - ) - - with pytest.raises(Exception): - asyncio.run(run()) - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - assert spans[0].status.status_code.name == "ERROR" - - @pytest.mark.usefixtures("instrument_with_content") - def test_async_chat_with_content(self, span_exporter): - async def run(): - client = _make_async_client( - response_json=_chat_response_json( - content_text="Async content capture test" - ) - ) - await client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Test content"}], - ) - - asyncio.run(run()) - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - attrs = dict(spans[0].attributes) - assert GenAIAttributes.GEN_AI_INPUT_MESSAGES in attrs - assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES in attrs - assert "Test content" in attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES] - assert "Async content capture test" in attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES] diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py deleted file mode 100644 index c4bd4c1ab3..0000000000 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_chat_completions.py +++ /dev/null @@ -1,252 +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. - -"""Tests for Cohere chat completions instrumentation.""" - -import httpx -import pytest - -from opentelemetry.instrumentation.cohere import CohereInstrumentor -from opentelemetry.semconv._incubating.attributes import ( - gen_ai_attributes as GenAIAttributes, -) -from opentelemetry.semconv.attributes import ( - server_attributes as ServerAttributes, -) - - -def _chat_response_json( - response_id="test-response-id", - finish_reason="COMPLETE", - content_text="Hello! How can I help you?", - input_tokens=10, - output_tokens=20, -): - return { - "id": response_id, - "finish_reason": finish_reason, - "message": { - "role": "assistant", - "content": [{"type": "text", "text": content_text}], - }, - "usage": { - "tokens": { - "input_tokens": input_tokens, - "output_tokens": output_tokens, - }, - }, - } - - -def _make_client(response_json=None, handler=None): - """Create a Cohere ClientV2 with a mock HTTP transport.""" - if handler is None: - body = response_json or _chat_response_json() - - def handler(request): - return httpx.Response(200, json=body) - - transport = httpx.MockTransport(handler) - httpx_client = httpx.Client(transport=transport) - - from cohere import ClientV2 - - return ClientV2(api_key="test-key", httpx_client=httpx_client) - - - -class TestChatCompletionsNoContent: - """Test sync chat completions without content capture.""" - - @pytest.mark.usefixtures("instrument_no_content") - def test_chat_basic(self, span_exporter): - client = _make_client() - response = client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello"}], - ) - - assert response.id == "test-response-id" - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - - span = spans[0] - assert span.name == "chat command-r-plus" - attrs = dict(span.attributes) - assert attrs[GenAIAttributes.GEN_AI_OPERATION_NAME] == "chat" - assert attrs[GenAIAttributes.GEN_AI_REQUEST_MODEL] == "command-r-plus" - assert attrs[GenAIAttributes.GEN_AI_PROVIDER_NAME] == "cohere" - assert attrs[GenAIAttributes.GEN_AI_USAGE_INPUT_TOKENS] == 10 - assert attrs[GenAIAttributes.GEN_AI_USAGE_OUTPUT_TOKENS] == 20 - assert attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ("stop",) - assert attrs[GenAIAttributes.GEN_AI_RESPONSE_ID] == "test-response-id" - assert attrs[ServerAttributes.SERVER_ADDRESS] == "api.cohere.com" - - # No content should be captured - assert GenAIAttributes.GEN_AI_INPUT_MESSAGES not in attrs - assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES not in attrs - - @pytest.mark.usefixtures("instrument_no_content") - def test_chat_with_optional_params(self, span_exporter): - client = _make_client() - client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello"}], - temperature=0.7, - max_tokens=100, - p=0.9, - frequency_penalty=0.5, - presence_penalty=0.3, - seed=42, - stop_sequences=["END"], - ) - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - attrs = dict(spans[0].attributes) - assert attrs[GenAIAttributes.GEN_AI_REQUEST_TEMPERATURE] == 0.7 - assert attrs[GenAIAttributes.GEN_AI_REQUEST_MAX_TOKENS] == 100 - assert attrs[GenAIAttributes.GEN_AI_REQUEST_TOP_P] == 0.9 - assert attrs[GenAIAttributes.GEN_AI_REQUEST_FREQUENCY_PENALTY] == 0.5 - assert attrs[GenAIAttributes.GEN_AI_REQUEST_PRESENCE_PENALTY] == 0.3 - assert attrs[GenAIAttributes.GEN_AI_REQUEST_SEED] == 42 - assert attrs[GenAIAttributes.GEN_AI_REQUEST_STOP_SEQUENCES] == ("END",) - - @pytest.mark.usefixtures("instrument_no_content") - def test_chat_max_tokens_finish(self, span_exporter): - client = _make_client( - response_json=_chat_response_json(finish_reason="MAX_TOKENS") - ) - client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello"}], - ) - - spans = span_exporter.get_finished_spans() - attrs = dict(spans[0].attributes) - assert attrs[GenAIAttributes.GEN_AI_RESPONSE_FINISH_REASONS] == ("length",) - - @pytest.mark.usefixtures("instrument_no_content") - def test_chat_error(self, span_exporter): - def error_handler(request): - raise httpx.ConnectError("Connection refused") - - client = _make_client(handler=error_handler) - with pytest.raises(Exception): - client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello"}], - ) - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - span = spans[0] - assert span.status.status_code.name == "ERROR" - - -class TestChatCompletionsWithContent: - """Test sync chat completions with content capture enabled.""" - - @pytest.mark.usefixtures("instrument_with_content") - def test_chat_captures_content(self, span_exporter): - client = _make_client( - response_json=_chat_response_json( - content_text="I'm doing great, thanks!" - ) - ) - client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "How are you?"}], - ) - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - attrs = dict(spans[0].attributes) - - assert GenAIAttributes.GEN_AI_INPUT_MESSAGES in attrs - assert GenAIAttributes.GEN_AI_OUTPUT_MESSAGES in attrs - - input_msgs = attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES] - assert "How are you?" in input_msgs - - output_msgs = attrs[GenAIAttributes.GEN_AI_OUTPUT_MESSAGES] - assert "I'm doing great, thanks!" in output_msgs - - @pytest.mark.usefixtures("instrument_with_content") - def test_chat_multi_message(self, span_exporter): - client = _make_client( - response_json=_chat_response_json( - content_text="The capital of France is Paris." - ) - ) - client.chat( - model="command-r-plus", - messages=[ - {"role": "system", "content": "You are a geography expert."}, - {"role": "user", "content": "What is the capital of France?"}, - ], - ) - - spans = span_exporter.get_finished_spans() - assert len(spans) == 1 - attrs = dict(spans[0].attributes) - input_msgs = attrs[GenAIAttributes.GEN_AI_INPUT_MESSAGES] - assert "geography expert" in input_msgs - assert "capital of France" in input_msgs - - - -class TestUninstrument: - """Test that uninstrumenting properly restores original methods.""" - - def test_uninstrument( - self, tracer_provider, logger_provider, meter_provider, span_exporter - ): - import os - - from opentelemetry.instrumentation._semconv import ( - OTEL_SEMCONV_STABILITY_OPT_IN, - _OpenTelemetrySemanticConventionStability, - ) - - _OpenTelemetrySemanticConventionStability._initialized = False - os.environ[OTEL_SEMCONV_STABILITY_OPT_IN] = "gen_ai_latest_experimental" - - instrumentor = CohereInstrumentor() - instrumentor.instrument( - tracer_provider=tracer_provider, - logger_provider=logger_provider, - meter_provider=meter_provider, - ) - - client = _make_client() - client.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello"}], - ) - assert len(span_exporter.get_finished_spans()) == 1 - - instrumentor.uninstrument() - span_exporter.clear() - - client2 = _make_client() - client2.chat( - model="command-r-plus", - messages=[{"role": "user", "content": "Hello again"}], - ) - # After uninstrument, no new spans should be created - assert len(span_exporter.get_finished_spans()) == 0 - - os.environ.pop(OTEL_SEMCONV_STABILITY_OPT_IN, None) diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py deleted file mode 100644 index baaf7e5119..0000000000 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_utils.py +++ /dev/null @@ -1,70 +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. - -"""Tests for Cohere instrumentation utility functions.""" - -from types import SimpleNamespace - -import pytest - -from opentelemetry.instrumentation.cohere.utils import ( - get_server_address_and_port, - map_finish_reason, -) - - -class TestMapFinishReason: - def test_complete(self): - assert map_finish_reason("COMPLETE") == "stop" - - def test_stop_sequence(self): - assert map_finish_reason("STOP_SEQUENCE") == "stop" - - def test_max_tokens(self): - assert map_finish_reason("MAX_TOKENS") == "length" - - def test_tool_call(self): - assert map_finish_reason("TOOL_CALL") == "tool_calls" - - def test_error(self): - assert map_finish_reason("ERROR") == "error" - - def test_timeout(self): - assert map_finish_reason("TIMEOUT") == "error" - - def test_none(self): - assert map_finish_reason(None) == "error" - - def test_unknown(self): - assert map_finish_reason("UNKNOWN_REASON") == "unknown_reason" - - -class TestGetServerAddressAndPort: - def test_default_address(self): - client = SimpleNamespace() - address, port = get_server_address_and_port(client) - assert address == "api.cohere.com" - assert port is None - - def test_custom_base_url(self): - client = SimpleNamespace(base_url="https://custom.cohere.example.com:8443/v2") - address, port = get_server_address_and_port(client) - assert address == "custom.cohere.example.com" - assert port == 8443 - - def test_standard_https_port_omitted(self): - client = SimpleNamespace(base_url="https://api.cohere.com:443/v2") - address, port = get_server_address_and_port(client) - assert address == "api.cohere.com" - assert port is None From a1b986831d1c9da7555a57efa2813d148b2fde08 Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Sun, 19 Apr 2026 13:58:52 -0700 Subject: [PATCH 4/7] clean up scaffold docs and add instrumentor smoke tests Trim changelog to reflect scaffolding only, clarify README sections that referenced chat completions not yet implemented, remove _supports_metrics since the stub emits nothing, and add basic test_instrumentor.py following the Anthropic test pattern. --- .../CHANGELOG.md | 9 +-- .../README.rst | 7 +- .../examples/manual/README.rst | 8 +-- .../instrumentation/cohere/package.py | 2 - .../tests/conftest.py | 64 +++++++++++++++++ .../tests/test_instrumentor.py | 68 +++++++++++++++++++ 6 files changed, 143 insertions(+), 15 deletions(-) create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py create mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_instrumentor.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md b/instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md index 79aeb373ed..e404a518e6 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/CHANGELOG.md @@ -9,10 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial implementation of Cohere instrumentation +- Initial scaffolding for Cohere instrumentation package ([#4418](https://github.com/open-telemetry/opentelemetry-python-contrib/pull/4418)) -- Implement sync and async `V2Client.chat` instrumentation with GenAI semantic convention attributes - - Captures request attributes: `gen_ai.request.model`, `gen_ai.request.max_tokens`, `gen_ai.request.temperature`, `gen_ai.request.top_p`, `gen_ai.request.frequency_penalty`, `gen_ai.request.presence_penalty`, `gen_ai.request.seed`, `gen_ai.request.stop_sequences` - - Captures response attributes: `gen_ai.response.id`, `gen_ai.response.model`, `gen_ai.response.finish_reasons`, `gen_ai.usage.input_tokens`, `gen_ai.usage.output_tokens` - - Error handling with `error.type` attribute - - Minimum supported cohere version is 5.0.0 +- Package structure, CI integration, examples, and stub instrumentor +- Minimum supported cohere version is 5.0.0 diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst index 2e218885d5..c19c415396 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst @@ -48,13 +48,16 @@ Configuration Capture Message Content *********************** -By default, prompts and completions are not captured. To enable message content capture, -set the environment variable: +By default, prompts and completions are not captured once chat instrumentation +is added. To enable message content capture, set the environment variable: :: export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true +Note: This setting has no effect until chat completions support is added +in a follow-up PR. + References ---------- diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst index 54853c4cee..e28379e5cd 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst @@ -4,11 +4,9 @@ OpenTelemetry Cohere Instrumentation Example This is an example of how to instrument Cohere calls when configuring OpenTelemetry SDK and Instrumentations manually. -When `main.py `_ is run, it exports traces and logs to an OTLP -compatible endpoint. Traces include details such as the model used and the -duration of the chat request. Logs capture the chat request and the generated -response, providing a comprehensive view of the performance and behavior of -your Cohere requests. +When chat completions support is added in a follow-up PR, `main.py `_ +will export traces and logs to an OTLP compatible endpoint. For now the +instrumentor is a no-op scaffold. Note: `.env <.env>`_ file configures additional environment variables: diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py index 60ee8acda8..874616e539 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/package.py @@ -14,5 +14,3 @@ _instruments = ("cohere >= 5.0.0",) - -_supports_metrics = True diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py new file mode 100644 index 0000000000..1c209d580d --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/conftest.py @@ -0,0 +1,64 @@ +# 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. + +"""Test fixtures for Cohere instrumentation tests.""" + +import pytest + +from opentelemetry.sdk._logs import LoggerProvider +from opentelemetry.sdk._logs.export import ( + InMemoryLogExporter, + SimpleLogRecordProcessor, +) +from opentelemetry.sdk.metrics import MeterProvider +from opentelemetry.sdk.metrics.export import InMemoryMetricReader +from opentelemetry.sdk.trace import TracerProvider +from opentelemetry.sdk.trace.export import SimpleSpanProcessor +from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( + InMemorySpanExporter, +) + + +@pytest.fixture +def span_exporter(): + return InMemorySpanExporter() + + +@pytest.fixture +def tracer_provider(span_exporter): + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(span_exporter)) + return provider + + +@pytest.fixture +def log_exporter(): + return InMemoryLogExporter() + + +@pytest.fixture +def logger_provider(log_exporter): + provider = LoggerProvider() + provider.add_log_record_processor(SimpleLogRecordProcessor(log_exporter)) + return provider + + +@pytest.fixture +def metric_reader(): + return InMemoryMetricReader() + + +@pytest.fixture +def meter_provider(metric_reader): + return MeterProvider(metric_readers=[metric_reader]) diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_instrumentor.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_instrumentor.py new file mode 100644 index 0000000000..5f8fa38993 --- /dev/null +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/tests/test_instrumentor.py @@ -0,0 +1,68 @@ +# 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. + +"""Tests for the CohereInstrumentor class.""" + +from opentelemetry.instrumentation.cohere import CohereInstrumentor + + +def test_instrumentor_instantiation(): + instrumentor = CohereInstrumentor() + assert instrumentor is not None + assert isinstance(instrumentor, CohereInstrumentor) + + +def test_instrumentation_dependencies(): + instrumentor = CohereInstrumentor() + dependencies = instrumentor.instrumentation_dependencies() + + assert dependencies is not None + assert len(dependencies) > 0 + assert "cohere >= 5.0.0" in dependencies + + +def test_instrument_uninstrument_cycle( + tracer_provider, logger_provider, meter_provider +): + instrumentor = CohereInstrumentor() + + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + instrumentor.uninstrument() + + instrumentor.instrument( + tracer_provider=tracer_provider, + logger_provider=logger_provider, + meter_provider=meter_provider, + ) + instrumentor.uninstrument() + + +def test_uninstrument_without_instrument(): + instrumentor = CohereInstrumentor() + instrumentor.uninstrument() + + +def test_instrumentor_has_required_attributes(): + instrumentor = CohereInstrumentor() + + assert hasattr(instrumentor, "instrument") + assert hasattr(instrumentor, "uninstrument") + assert hasattr(instrumentor, "instrumentation_dependencies") + assert callable(instrumentor.instrument) + assert callable(instrumentor.uninstrument) + assert callable(instrumentor.instrumentation_dependencies) From bc5434b484a1a4f78bffa0a4760863f58a9ba390 Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Sat, 2 May 2026 14:34:38 -0700 Subject: [PATCH 5/7] remove empty utils placeholder, kept for follow-up PR --- .../opentelemetry/instrumentation/cohere/utils.py | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py deleted file mode 100644 index b2d1f6b9bf..0000000000 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/utils.py +++ /dev/null @@ -1,15 +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. - -# TODO: Chat completions utility helpers will be added in a follow-up PR. From ed171f86200f0883322b1588dfbe347bbcf26680 Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Mon, 4 May 2026 22:40:21 -0700 Subject: [PATCH 6/7] fix scaffold docs and bump util-genai pin Removed misleading client.chat() usage snippets since _instrument() is still a no-op. Updated README and examples to clearly say chat completions will land in a follow-up. Bumped opentelemetry-util-genai pin to >= 0.4b0.dev, <0.5b0 to match other GenAI packages. Dropped py39 from CI and tox since util-genai needs 3.10+. --- .github/workflows/test.yml | 36 ---------------- .../README.rst | 43 ++++--------------- .../examples/manual/README.rst | 12 +++--- .../examples/zero-code/README.rst | 16 ++++--- .../pyproject.toml | 5 +-- .../instrumentation/cohere/__init__.py | 14 +++--- tox.ini | 2 +- 7 files changed, 34 insertions(+), 94 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c1fb967aa7..0d91df7a6c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1031,43 +1031,7 @@ jobs: - name: Run tests run: tox -e py314-test-instrumentation-anthropic-latest -- -ra - py39-test-instrumentation-cohere-oldest_ubuntu-latest: - name: instrumentation-cohere-oldest 3.9 Ubuntu - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python 3.9 - uses: actions/setup-python@v5 - with: - python-version: "3.9" - - - name: Install tox - run: pip install tox-uv - - - name: Run tests - run: tox -e py39-test-instrumentation-cohere-oldest -- -ra - py39-test-instrumentation-cohere-latest_ubuntu-latest: - name: instrumentation-cohere-latest 3.9 Ubuntu - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - name: Checkout repo @ SHA - ${{ github.sha }} - uses: actions/checkout@v4 - - - name: Set up Python 3.9 - uses: actions/setup-python@v5 - with: - python-version: "3.9" - - - name: Install tox - run: pip install tox-uv - - - name: Run tests - run: tox -e py39-test-instrumentation-cohere-latest -- -ra py310-test-instrumentation-cohere-oldest_ubuntu-latest: name: instrumentation-cohere-oldest 3.10 Ubuntu diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst index c19c415396..3b8a6aead3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/README.rst @@ -6,7 +6,12 @@ OpenTelemetry Cohere Instrumentation .. |pypi| image:: https://badge.fury.io/py/opentelemetry-instrumentation-cohere.svg :target: https://pypi.org/project/opentelemetry-instrumentation-cohere/ -This library allows tracing applications that use the `Cohere Python SDK `_. +This library provides instrumentation for applications that use the `Cohere Python SDK `_. + +.. note:: + This package is currently a scaffold. Chat completions instrumentation + will be added in a follow-up PR. Installing and calling ``instrument()`` + is safe but will not produce spans or logs until then. Installation ------------ @@ -15,48 +20,18 @@ Installation pip install opentelemetry-instrumentation-cohere -If you don't have a Cohere application yet, try our `examples `_ -which only need a valid Cohere API key. - -Check out the `zero-code example `_ for a quick start. - Usage ----- -This section describes how to set up Cohere instrumentation if you're setting OpenTelemetry up manually. -Check out the `manual example `_ for more details. - .. code-block:: python - from cohere import ClientV2 from opentelemetry.instrumentation.cohere import CohereInstrumentor CohereInstrumentor().instrument() - client = ClientV2() - response = client.chat( - model="command-r-plus", - messages=[ - {"role": "user", "content": "Hello, how are you?"}, - ], - ) - - -Configuration -------------- - -Capture Message Content -*********************** - -By default, prompts and completions are not captured once chat instrumentation -is added. To enable message content capture, set the environment variable: - -:: - - export OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true - -Note: This setting has no effect until chat completions support is added -in a follow-up PR. + # Chat completions patching will be wired up in a follow-up PR. + # Once that lands, Cohere SDK calls will automatically produce + # traces and logs. References diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst index e28379e5cd..d5201dbea2 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/manual/README.rst @@ -10,9 +10,10 @@ instrumentor is a no-op scaffold. Note: `.env <.env>`_ file configures additional environment variables: -- `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` configures - Cohere instrumentation to capture prompt and completion contents on - events. +- `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` is the setting + that will control whether Cohere instrumentation captures prompt and + completion contents on events, but it currently has no effect because + chat completions support has not yet been added. Setup ----- @@ -44,5 +45,6 @@ Run the example like this: dotenv run -- python main.py -You should see a poem generated by Cohere while traces and logs export to your -configured observability tool. +At the moment, this example runs the placeholder scaffold in ``main.py``. +Once chat completions support is added, Cohere SDK calls will produce traces +and logs exported to your configured observability tool. diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst index 0dcc653a51..82d87a2332 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/examples/zero-code/README.rst @@ -5,14 +5,15 @@ This is an example of how to use OpenTelemetry's automatic instrumentation (zero-code) capabilities with the Cohere SDK. The `opentelemetry-instrument` CLI automatically instruments your Python -application without requiring code changes. When `main.py `_ is run -with the CLI, it exports traces and logs to an OTLP compatible endpoint. +application without requiring code changes. Once chat completions support is +added in a follow-up PR, running `main.py `_ with the CLI will +export traces and logs to an OTLP compatible endpoint. Note: `.env <.env>`_ file configures additional environment variables: -- `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` configures - Cohere instrumentation to capture prompt and completion contents on - events. +- `OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT=true` is included for + consistency with other GenAI instrumentations, but it currently has no + effect for Cohere. Chat completions support has not yet been implemented. Setup ----- @@ -44,8 +45,9 @@ Run the example with zero-code instrumentation like this: dotenv run -- opentelemetry-instrument python main.py -You should see a poem generated by Cohere while traces and logs export to your -configured observability tool. No changes to `main.py` were required! +This runs `main.py` under automatic instrumentation. Once chat completions +support is added in a follow-up PR, Cohere SDK calls will produce traces and +logs exported to your configured observability tool without code changes. Learn More ---------- diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml index 6cddf0eff3..e88de983a3 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "OpenTelemetry Cohere instrumentation" readme = "README.rst" license = "Apache-2.0" -requires-python = ">=3.9" +requires-python = ">=3.10" authors = [ { name = "OpenTelemetry Authors", email = "cncf-opentelemetry-contributors@lists.cncf.io" }, ] @@ -18,7 +18,6 @@ classifiers = [ "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -29,7 +28,7 @@ dependencies = [ "opentelemetry-api ~= 1.39", "opentelemetry-instrumentation ~= 0.60b0", "opentelemetry-semantic-conventions ~= 0.60b0", - "opentelemetry-util-genai", + "opentelemetry-util-genai >= 0.4b0.dev, <0.5b0", ] [project.optional-dependencies] diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py index dafcee470c..fefac8d591 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/src/opentelemetry/instrumentation/cohere/__init__.py @@ -16,6 +16,11 @@ Cohere client instrumentation supporting `cohere`, it can be enabled by using ``CohereInstrumentor``. +.. note:: + This package is currently a scaffold. Chat completions instrumentation + will be added in a follow-up PR. Installing and calling instrument() is + safe but will not produce spans or logs until then. + .. _cohere: https://pypi.org/project/cohere/ Usage @@ -23,18 +28,11 @@ .. code:: python - from cohere import ClientV2 from opentelemetry.instrumentation.cohere import CohereInstrumentor CohereInstrumentor().instrument() - client = ClientV2() - response = client.chat( - model="command-r-plus", - messages=[ - {"role": "user", "content": "Write a short poem on open telemetry."}, - ], - ) + # Chat completions patching will be wired up in a follow-up PR. API --- diff --git a/tox.ini b/tox.ini index dde15d65d8..1a695f9515 100644 --- a/tox.ini +++ b/tox.ini @@ -37,7 +37,7 @@ envlist = lint-instrumentation-anthropic ; instrumentation-cohere - py3{9,10,11,12,13,14}-test-instrumentation-cohere-{oldest,latest} + py3{10,11,12,13,14}-test-instrumentation-cohere-{oldest,latest} lint-instrumentation-cohere ; instrumentation-claude-agent-sdk From c5a6504e8752ffe5e3e4e0cef73ad8dd8bb608de Mon Sep 17 00:00:00 2001 From: Nik-Reddy Date: Wed, 6 May 2026 15:19:00 -0700 Subject: [PATCH 7/7] widen util-genai version range to cover 0.2-0.5 --- .../opentelemetry-instrumentation-cohere/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml index e88de983a3..aa11af6b97 100644 --- a/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml +++ b/instrumentation-genai/opentelemetry-instrumentation-cohere/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "opentelemetry-api ~= 1.39", "opentelemetry-instrumentation ~= 0.60b0", "opentelemetry-semantic-conventions ~= 0.60b0", - "opentelemetry-util-genai >= 0.4b0.dev, <0.5b0", + "opentelemetry-util-genai >= 0.2b0, <0.6b0", ] [project.optional-dependencies]