Skip to content

Commit 6989256

Browse files
nhoffmann-newrelicykriger-newrelic
andauthored
Added new events for chat completions (newrelic#11)
* Added new events for chat completions * store headers by completion key * Add "is_patched" attribute to patched functions * add request.model and response.model to the events * change rate-limit values to numbers from strings * generate events for requests with errors * Added LlmEmbedding * add support for async create * clean up get_rate_limit_data * log exception on the root level instead of raising * Change error handling * move handling to decorator * raise exceptions from openai's original methods * added ingest source to events * Update README.md * Update README.md * Update README.md * Update README.md * bugfix: stream=true was breaking client apps * fix stream=true for async functions * calculate response time internally * Limit message content to 4095 chars * add message uuid * added support for "engine" instead of "model" --------- Co-authored-by: ykriger-newrelic <97446848+ykriger-newrelic@users.noreply.github.com>
1 parent 9138540 commit 6989256

File tree

5 files changed

+582
-31
lines changed

5 files changed

+582
-31
lines changed

.gitignore

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
# Byte-compiled / optimized / DLL files
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
6+
# C extensions
7+
*.so
8+
9+
# Distribution / packaging
10+
.Python
11+
build/
12+
develop-eggs/
13+
dist/
14+
downloads/
15+
eggs/
16+
.eggs/
17+
lib/
18+
lib64/
19+
parts/
20+
sdist/
21+
var/
22+
wheels/
23+
share/python-wheels/
24+
*.egg-info/
25+
.installed.cfg
26+
*.egg
27+
MANIFEST
28+
29+
# PyInstaller
30+
# Usually these files are written by a python script from a template
31+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
32+
*.manifest
33+
*.spec
34+
35+
# Installer logs
36+
pip-log.txt
37+
pip-delete-this-directory.txt
38+
39+
# Unit test / coverage reports
40+
htmlcov/
41+
.tox/
42+
.nox/
43+
.coverage
44+
.coverage.*
45+
.cache
46+
nosetests.xml
47+
coverage.xml
48+
*.cover
49+
*.py,cover
50+
.hypothesis/
51+
.pytest_cache/
52+
cover/
53+
54+
# Translations
55+
*.mo
56+
*.pot
57+
58+
# Django stuff:
59+
*.log
60+
local_settings.py
61+
db.sqlite3
62+
db.sqlite3-journal
63+
64+
# Flask stuff:
65+
instance/
66+
.webassets-cache
67+
68+
# Scrapy stuff:
69+
.scrapy
70+
71+
# Sphinx documentation
72+
docs/_build/
73+
74+
# PyBuilder
75+
.pybuilder/
76+
target/
77+
78+
# Jupyter Notebook
79+
.ipynb_checkpoints
80+
81+
# IPython
82+
profile_default/
83+
ipython_config.py
84+
85+
# pyenv
86+
# For a library or package, you might want to ignore these files since the code is
87+
# intended to run in multiple environments; otherwise, check them in:
88+
# .python-version
89+
90+
# pipenv
91+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
93+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
94+
# install all needed dependencies.
95+
#Pipfile.lock
96+
97+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
98+
__pypackages__/
99+
100+
# Celery stuff
101+
celerybeat-schedule
102+
celerybeat.pid
103+
104+
# SageMath parsed files
105+
*.sage.py
106+
107+
# Environments
108+
.env
109+
.venv
110+
env/
111+
venv/
112+
ENV/
113+
env.bak/
114+
venv.bak/
115+
116+
# Spyder project settings
117+
.spyderproject
118+
.spyproject
119+
120+
# Rope project settings
121+
.ropeproject
122+
123+
# mkdocs documentation
124+
/site
125+
126+
# mypy
127+
.mypy_cache/
128+
.dmypy.json
129+
dmypy.json
130+
131+
# Pyre type checker
132+
.pyre/
133+
134+
# pytype static type analyzer
135+
.pytype/
136+
137+
# Cython debug symbols
138+
cython_debug/
139+
140+
# poetry
141+
poetry.lock
142+
143+
# vscode
144+
.vscode
145+
146+
#pyenv
147+
.python-version
148+
149+
#git
150+
*.patch

README.md

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@ os.environ["NEW_RELIC_LICENSE_KEY"] = "<license key>"
3939

4040
```python
4141
from nr_openai_observability import monitor
42-
monitor.initialization()
42+
monitor.initialization(
43+
application_name="OpenAI observability example"
44+
)
4345
```
4446

4547
#### Code example:
@@ -51,15 +53,21 @@ import os
5153
import openai
5254
from nr_openai_observability import monitor
5355

54-
monitor.initialization()
56+
monitor.initialization(
57+
application_name="OpenAI observability example"
58+
)
5559

5660
openai.api_key = os.getenv("OPENAI_API_KEY")
57-
openai.Completion.create(
58-
model="text-davinci-003",
59-
prompt="What is Observability?",
60-
max_tokens=20,
61-
temperature=0
61+
response = openai.ChatCompletion.create(
62+
model="gpt-3.5-turbo",
63+
messages=[
64+
{
65+
"role": "user",
66+
"content": "Write a rhythm about observability",
67+
},
68+
],
6269
)
70+
print(response["choices"][0]["message"]["content"])
6371
```
6472

6573
#### STEP 3: Follow the instruction [here](https://one.newrelic.com/launcher/catalog-pack-details.launcher/?pane=eyJuZXJkbGV0SWQiOiJjYXRhbG9nLXBhY2stZGV0YWlscy5jYXRhbG9nLXBhY2stY29udGVudHMiLCJxdWlja3N0YXJ0SWQiOiI1ZGIyNWRiZC1hNmU5LTQ2ZmMtYTcyOC00Njk3ZjY3N2ZiYzYifQ==) to add the dashboard to your New Relic account.
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
import uuid
2+
from datetime import datetime
3+
4+
import openai
5+
6+
7+
def _build_messages_events(messages, completion_id, model):
8+
message_id = str(uuid.uuid4())
9+
events = []
10+
for index, message in enumerate(messages):
11+
currMessage = {
12+
"id": message_id,
13+
"content": message.get("content")[:4095],
14+
"role": message.get("role"),
15+
"completion_id": completion_id,
16+
"sequence": index,
17+
"model": model,
18+
"vendor": "openAI",
19+
"ingest_source": "PythonSDK",
20+
}
21+
22+
events.append(currMessage)
23+
24+
return events
25+
26+
27+
def _get_rate_limit_data(response_headers):
28+
def _get_numeric_header(name):
29+
header = response_headers.get(name)
30+
return int(header) if header and header.isdigit() else None
31+
32+
return {
33+
"ratelimit_limit_requests": _get_numeric_header("ratelimit_limit_requests"),
34+
"ratelimit_limit_tokens": _get_numeric_header("ratelimit_limit_tokens"),
35+
"ratelimit_reset_tokens": response_headers.get("x-ratelimit-reset-tokens"),
36+
"ratelimit_reset_requests": response_headers.get("x-ratelimit-reset-requests"),
37+
"ratelimit_remaining_tokens": _get_numeric_header("ratelimit_remaining_tokens"),
38+
"ratelimit_remaining_requests": _get_numeric_header(
39+
"ratelimit_remaining_requests"
40+
),
41+
}
42+
43+
44+
def build_completion_events(response, request, response_headers, response_time):
45+
completion_id = str(uuid.uuid4())
46+
47+
completion = {
48+
"id": completion_id,
49+
"api_key_last_four_digits": f"sk-{response.api_key[-4:]}",
50+
"timestamp": datetime.now(),
51+
"response_time": int(response_time * 1000),
52+
"request.model": request.get("model") or request.get("engine"),
53+
"response.model": response.model,
54+
"usage.completion_tokens": response.usage.completion_tokens,
55+
"usage.total_tokens": response.usage.total_tokens,
56+
"usage.prompt_tokens": response.usage.prompt_tokens,
57+
"temperature": request.get("temperature"),
58+
"max_tokens": request.get("max_tokens"),
59+
"finish_reason": response.choices[0].finish_reason,
60+
"api_type": response.api_type,
61+
"vendor": "openAI",
62+
"ingest_source": "PythonSDK",
63+
"number_of_messages": len(request.get("messages", [])) + len(response.choices),
64+
"organization": response.organization,
65+
"api_version": response_headers.get("openai-version"),
66+
}
67+
68+
completion.update(_get_rate_limit_data(response_headers))
69+
70+
messages = _build_messages_events(
71+
request.get("messages", []) + [response.choices[0].message],
72+
completion_id,
73+
response.model,
74+
)
75+
76+
return {"messages": messages, "completion": completion}
77+
78+
79+
def build_completion_error_events(request, error):
80+
completion_id = str(uuid.uuid4())
81+
82+
completion = {
83+
"id": completion_id,
84+
"api_key_last_four_digits": f"sk-{openai.api_key[-4:]}",
85+
"timestamp": datetime.now(),
86+
"request.model": request.get("model") or request.get("engine"),
87+
"temperature": request.get("temperature"),
88+
"max_tokens": request.get("max_tokens"),
89+
"vendor": "openAI",
90+
"ingest_source": "PythonSDK",
91+
"organization": error.organization,
92+
"number_of_messages": len(request.get("messages", [])),
93+
"error_status": error.http_status,
94+
"error_message": error.error.message,
95+
"error_type": error.error.type,
96+
"error_code": error.error.code,
97+
"error_param": error.error.param,
98+
}
99+
100+
messages = _build_messages_events(
101+
request.get("messages", []),
102+
completion_id,
103+
request.get("model") or request.get("engine"),
104+
)
105+
106+
return {"messages": messages, "completion": completion}
107+
108+
109+
def build_embedding_event(response, request, response_headers, response_time):
110+
embedding_id = str(uuid.uuid4())
111+
112+
embedding = {
113+
"id": embedding_id,
114+
"api_key_last_four_digits": f"sk-{response.api_key[-4:]}",
115+
"timestamp": datetime.now(),
116+
"response_time": int(response_time * 1000),
117+
"request.model": request.get("model") or request.get("engine"),
118+
"response.model": response.model,
119+
"usage.total_tokens": response.usage.total_tokens,
120+
"usage.prompt_tokens": response.usage.prompt_tokens,
121+
"api_type": response.api_type,
122+
"vendor": "openAI",
123+
"ingest_source": "PythonSDK",
124+
"organization": response.organization,
125+
"api_version": response_headers.get("openai-version"),
126+
}
127+
128+
embedding.update(_get_rate_limit_data(response_headers))
129+
return embedding
130+
131+
132+
def build_embedding_error_event(request, error):
133+
embedding_id = str(uuid.uuid4())
134+
135+
embedding = {
136+
"id": embedding_id,
137+
"api_key_last_four_digits": f"sk-{openai.api_key[-4:]}",
138+
"timestamp": datetime.now(),
139+
"request.model": request.get("model") or request.get("engine"),
140+
"vendor": "openAI",
141+
"ingest_source": "PythonSDK",
142+
"organization": error.organization,
143+
"error_status": error.http_status,
144+
"error_message": error.error.message,
145+
"error_type": error.error.type,
146+
"error_code": error.error.code,
147+
"error_param": error.error.param,
148+
}
149+
150+
return embedding
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import logging
2+
from functools import wraps
3+
4+
logger = logging.getLogger("nr_openai_observability")
5+
6+
7+
def handle_errors(func):
8+
@wraps(func)
9+
def wrapper(*args, **kwargs):
10+
try:
11+
return func(*args, **kwargs)
12+
except Exception as err:
13+
logger.error(f"An error occurred in {func.__name__}: {err}")
14+
return wrapper

0 commit comments

Comments
 (0)