Skip to content
This repository was archived by the owner on Nov 13, 2024. It is now read-only.

Commit bd1c039

Browse files
Slightly improve error handling for external providers (#220)
* [kb] Improve error handling for OpenAIRecord encoder Catch specific OpenAI errors and re-raise with a clear message * [kb] Improve the error in case of failing to infer dimensionality * Added option to ignore specific warnings, or ignore warnings from a specific module In addition, the `transformers` module is very insisstent, so I added their dedicated mechanism for silencing warnings * Bug fix - infering dimension incorrectly * [CLI] Removed dedicated error message for OpenAI auth problem Instead, individual RecordEncoders and LLMs would need to raise their own errors * [server] Call health_check() on startup To detect errors early * [cli] Bug fix - validate function did not connect to KB * [LLM] Explicit error when initializing and when using OpenAI components - both LLM and RecordEncoder * [kb] Slightly better solution for error handling Catch the error in RecordEncoder base class, then format for each inhertor differently * linter * revert accidental commit * [test] KB test - improved dimension infer testing 1. Needed to change after code was changed. 2. Added a few more test cases 3. Added more assertions on the naive create() * [test] Fix LLM tests after error handling Some error types changed * [cli] Improve error message in case of index already exists The CLI shouldn't mention `delete_index()` * [CLI] Improve ChatEngine validate - use less tokens Sending just "hello" without any limitations can use many tokens * [LLM] Explicit error for function calling failed Based on feedback in PR #220
1 parent 4eac053 commit bd1c039

File tree

11 files changed

+184
-62
lines changed

11 files changed

+184
-62
lines changed

src/canopy/__init__.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
11
import importlib.metadata
2+
import warnings
3+
import logging
4+
import os
5+
from typing import List
26

37
# Taken from https://stackoverflow.com/a/67097076
48
__version__ = importlib.metadata.version("canopy-sdk")
9+
10+
11+
IGNORED_WARNINGS: List[str] = [
12+
]
13+
14+
IGNORED_WARNING_IN_MODULES = [
15+
"transformers",
16+
]
17+
18+
for warning in IGNORED_WARNINGS:
19+
warnings.filterwarnings("ignore", message=warning)
20+
for module in IGNORED_WARNING_IN_MODULES:
21+
warnings.filterwarnings("ignore", module=module)
22+
logging.getLogger(module).setLevel(logging.ERROR)
23+
24+
# Apparently, `transformers` has its own logging system, and needs to be silenced separately # noqa: E501
25+
os.environ["TRANSFORMERS_VERBOSITY"] = "error"

src/canopy/knowledge_base/knowledge_base.py

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -307,24 +307,28 @@ def create_canopy_index(self,
307307
if dimension is None:
308308
try:
309309
encoder_dimension = self._encoder.dimension
310+
if encoder_dimension is None:
311+
raise RuntimeError(
312+
f"The selected encoder {self._encoder.__class__.__name__} does "
313+
f"not support inferring the vectors' dimensionality."
314+
)
315+
dimension = encoder_dimension
310316
except Exception as e:
311317
raise RuntimeError(
312-
f"Failed to infer vectors' dimension from encoder due to error: "
313-
f"{e}. Please fix the error or provide the dimension manually"
318+
f"Canopy has failed to infer vectors' dimensionality using the "
319+
f"selected encoder: {self._encoder.__class__.__name__}. You can "
320+
f"provide the dimension manually, try using a different encoder, or"
321+
f" fix the underlying error:\n{e}"
314322
) from e
315-
if encoder_dimension is not None:
316-
dimension = encoder_dimension
317-
else:
318-
raise ValueError("Could not infer dimension from encoder. "
319-
"Please provide the vectors' dimension")
320323

321324
# connect to pinecone and create index
322325
connect_to_pinecone()
323326

324327
if self.index_name in list_indexes():
325328
raise RuntimeError(
326-
f"Index {self.index_name} already exists. "
327-
"If you wish to delete it, use `delete_index()`. "
329+
f"Index {self.index_name} already exists. To connect to an "
330+
f"existing index, use `knowledge_base.connect()`. "
331+
"If you wish to delete it call `knowledge_base.delete_index()`. "
328332
)
329333

330334
# create index

src/canopy/knowledge_base/record_encoder/base.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,13 @@ def encode_documents(self, documents: List[KBDocChunk]) -> List[KBEncodedDocChun
100100
""" # noqa: E501
101101
encoded_docs = []
102102
for batch in self._batch_iterator(documents, self.batch_size):
103-
encoded_docs.extend(self._encode_documents_batch(batch))
103+
try:
104+
encoded_docs.extend(self._encode_documents_batch(batch))
105+
except Exception as e:
106+
raise RuntimeError(
107+
f"Failed to enconde documents using {self.__class__.__name__}. "
108+
f"Error: {self._format_error(e)}"
109+
) from e
104110

105111
return encoded_docs # TODO: consider yielding a generator
106112

@@ -118,7 +124,13 @@ def encode_queries(self, queries: List[Query]) -> List[KBQuery]:
118124

119125
kb_queries = []
120126
for batch in self._batch_iterator(queries, self.batch_size):
121-
kb_queries.extend(self._encode_queries_batch(batch))
127+
try:
128+
kb_queries.extend(self._encode_queries_batch(batch))
129+
except Exception as e:
130+
raise RuntimeError(
131+
f"Failed to enconde queries using {self.__class__.__name__}. "
132+
f"Error: {self._format_error(e)}"
133+
) from e
122134

123135
return kb_queries
124136

@@ -137,3 +149,6 @@ async def aencode_queries(self, queries: List[Query]) -> List[KBQuery]:
137149
kb_queries.extend(await self._aencode_queries_batch(batch))
138150

139151
return kb_queries
152+
153+
def _format_error(self, err):
154+
return f"{err}"

src/canopy/knowledge_base/record_encoder/dense.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ def dimension(self) -> int:
6464
Returns:
6565
dimension(int): the dimension of the encoder
6666
""" # noqa: E501
67-
return len(self._dense_encoder.encode_documents(["hello"])[0])
67+
dummy_doc = KBDocChunk(text="hello", id="dummy_doc", document_id="dummy_doc")
68+
return len(self.encode_documents([dummy_doc])[0].values)
6869

6970
async def _aencode_documents_batch(self,
7071
documents: List[KBDocChunk]

src/canopy/knowledge_base/record_encoder/openai.py

Lines changed: 28 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,19 @@
11
from typing import List
2+
3+
from openai import OpenAIError, RateLimitError, APIConnectionError, AuthenticationError
24
from pinecone_text.dense.openai_encoder import OpenAIEncoder
35
from canopy.knowledge_base.models import KBDocChunk, KBEncodedDocChunk, KBQuery
46
from canopy.knowledge_base.record_encoder.dense import DenseRecordEncoder
57
from canopy.models.data_models import Query
68

79

10+
def _format_openai_error(e):
11+
try:
12+
return e.response.json()['error']['message']
13+
except Exception:
14+
return str(e)
15+
16+
817
class OpenAIRecordEncoder(DenseRecordEncoder):
918
"""
1019
OpenAIRecordEncoder is a type of DenseRecordEncoder that uses the OpenAI `embeddings` API.
@@ -27,25 +36,31 @@ def __init__(self,
2736
Defaults to 400.
2837
**kwargs: Additional arguments to pass to the underlying `pinecone-text. OpenAIEncoder`.
2938
""" # noqa: E501
30-
encoder = OpenAIEncoder(model_name, **kwargs)
39+
try:
40+
encoder = OpenAIEncoder(model_name, **kwargs)
41+
except OpenAIError as e:
42+
raise RuntimeError(
43+
"Failed to connect to OpenAI, please make sure that the OPENAI_API_KEY "
44+
"environment variable is set correctly.\n"
45+
f"Error: {_format_openai_error(e)}"
46+
) from e
3147
super().__init__(dense_encoder=encoder, batch_size=batch_size)
3248

33-
def encode_documents(self, documents: List[KBDocChunk]) -> List[KBEncodedDocChunk]:
34-
"""
35-
Encode a list of documents, takes a list of KBDocChunk and returns a list of KBEncodedDocChunk.
36-
37-
Args:
38-
documents: A list of KBDocChunk to encode.
39-
40-
Returns:
41-
encoded chunks: A list of KBEncodedDocChunk, with the `values` field populated by the generated embeddings vector.
42-
""" # noqa: E501
43-
return super().encode_documents(documents)
44-
4549
async def _aencode_documents_batch(self,
4650
documents: List[KBDocChunk]
4751
) -> List[KBEncodedDocChunk]:
4852
raise NotImplementedError
4953

5054
async def _aencode_queries_batch(self, queries: List[Query]) -> List[KBQuery]:
5155
raise NotImplementedError
56+
57+
def _format_error(self, err):
58+
if isinstance(err, RateLimitError):
59+
return (f"Your OpenAI account seem to have reached the rate limit. "
60+
f"Details: {_format_openai_error(err)}")
61+
elif isinstance(err, (AuthenticationError, APIConnectionError)):
62+
return (f"Failed to connect to OpenAI, please make sure that the "
63+
f"OPENAI_API_KEY environment variable is set correctly. "
64+
f"Details: {_format_openai_error(err)}")
65+
else:
66+
return _format_openai_error(err)

src/canopy/llm/openai.py

Lines changed: 48 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,13 @@
1717
from canopy.models.data_models import Messages, Query
1818

1919

20+
def _format_openai_error(e):
21+
try:
22+
return e.response.json()['error']['message']
23+
except Exception:
24+
return str(e)
25+
26+
2027
class OpenAILLM(BaseLLM):
2128
"""
2229
OpenAI LLM wrapper built on top of the OpenAI Python client.
@@ -48,9 +55,17 @@ def __init__(self,
4855
These params can be overridden by passing a `model_params` argument to the `chat_completion` or `enforced_function_call` methods.
4956
""" # noqa: E501
5057
super().__init__(model_name)
51-
self._client = openai.OpenAI(api_key=api_key,
52-
organization=organization,
53-
base_url=base_url)
58+
try:
59+
self._client = openai.OpenAI(api_key=api_key,
60+
organization=organization,
61+
base_url=base_url)
62+
except openai.OpenAIError as e:
63+
raise RuntimeError(
64+
"Failed to connect to OpenAI, please make sure that the OPENAI_API_KEY "
65+
"environment variable is set correctly.\n"
66+
f"Error: {_format_openai_error(e)}"
67+
)
68+
5469
self.default_model_params = kwargs
5570

5671
@property
@@ -96,11 +111,19 @@ def chat_completion(self,
96111
)
97112

98113
messages = [m.dict() for m in messages]
99-
response = self._client.chat.completions.create(model=self.model_name,
100-
messages=messages,
101-
stream=stream,
102-
max_tokens=max_tokens,
103-
**model_params_dict)
114+
try:
115+
response = self._client.chat.completions.create(model=self.model_name,
116+
messages=messages,
117+
stream=stream,
118+
max_tokens=max_tokens,
119+
**model_params_dict)
120+
except openai.OpenAIError as e:
121+
provider_name = self.__class__.__name__.replace("LLM", "")
122+
raise RuntimeError(
123+
f"Failed to use {provider_name}'s {self.model_name} model for chat "
124+
f"completion.\n"
125+
f"Error: {_format_openai_error(e)}"
126+
)
104127

105128
def streaming_iterator(response):
106129
for chunk in response:
@@ -175,15 +198,23 @@ def enforced_function_call(self,
175198
function_dict = cast(ChatCompletionToolParam,
176199
{"type": "function", "function": function.dict()})
177200

178-
chat_completion = self._client.chat.completions.create(
179-
messages=[m.dict() for m in messages],
180-
model=self.model_name,
181-
tools=[function_dict],
182-
tool_choice={"type": "function",
183-
"function": {"name": function.name}},
184-
max_tokens=max_tokens,
185-
**model_params_dict
186-
)
201+
try:
202+
chat_completion = self._client.chat.completions.create(
203+
messages=[m.dict() for m in messages],
204+
model=self.model_name,
205+
tools=[function_dict],
206+
tool_choice={"type": "function",
207+
"function": {"name": function.name}},
208+
max_tokens=max_tokens,
209+
**model_params_dict
210+
)
211+
except openai.OpenAIError as e:
212+
provider_name = self.__class__.__name__.replace("LLM", "")
213+
raise RuntimeError(
214+
f"Failed to use {provider_name}'s {self.model_name} model for "
215+
f"chat completion with enforced function calling.\n"
216+
f"Error: {_format_openai_error(e)}"
217+
)
187218

188219
result = chat_completion.choices[0].message.tool_calls[0].function.arguments
189220
arguments = json.loads(result)

src/canopy_cli/cli.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
from canopy.knowledge_base import connect_to_pinecone
2323
from canopy.knowledge_base.chunker import Chunker
2424
from canopy.chat_engine import ChatEngine
25-
from canopy.models.data_models import Document
25+
from canopy.models.data_models import Document, UserMessage
2626
from canopy.tokenizer import Tokenizer
2727
from canopy_cli.data_loader import (
2828
load_from_path,
@@ -44,12 +44,6 @@
4444
DEFAULT_SERVER_URL = f"http://localhost:8000/{API_VERSION}"
4545
spinner = Spinner()
4646

47-
OPENAI_AUTH_ERROR_MSG = (
48-
"Failed to connect to OpenAI, please make sure that the OPENAI_API_KEY "
49-
"environment variable is set correctly.\n"
50-
"Please visit https://platform.openai.com/account/api-keys for more details"
51-
)
52-
5347

5448
def check_server_health(url: str):
5549
try:
@@ -140,9 +134,15 @@ def _validate_chat_engine(config_file: Optional[str]):
140134
config = _read_config_file(config_file)
141135
Tokenizer.initialize()
142136
try:
143-
ChatEngine.from_config(config.get("chat_engine", {}))
144-
except openai.OpenAIError:
145-
raise CLIError(OPENAI_AUTH_ERROR_MSG)
137+
# If the server itself will fail, we can't except the error, since it's running
138+
# in a different process. Try to load and run the ChatEngine so we can catch
139+
# any errors and print a nice message.
140+
chat_engine = ChatEngine.from_config(config.get("chat_engine", {}))
141+
chat_engine.max_generated_tokens = 5
142+
chat_engine.context_engine.knowledge_base.connect()
143+
chat_engine.chat(
144+
[UserMessage(content="This is a health check. Are you alive? Be concise")]
145+
)
146146
except Exception as e:
147147
msg = f"Failed to initialize Canopy server. Reason:\n{e}"
148148
if config_file:
@@ -229,9 +229,15 @@ def new(index_name: str, config: Optional[str]):
229229
with spinner:
230230
try:
231231
kb.create_canopy_index()
232-
# TODO: kb should throw a specific exception for each case
232+
# TODO: kb should throw a specific exception for failure
233233
except Exception as e:
234-
msg = f"Failed to create a new index. Reason:\n{e}"
234+
already_exists_str = f"Index {kb.index_name} already exists"
235+
if isinstance(e, RuntimeError) and already_exists_str in str(e):
236+
msg = (f"{already_exists_str}, please use a different name."
237+
f"If you wish to delete the index, log in to Pinecone's "
238+
f"Console: https://app.pinecone.io/")
239+
else:
240+
msg = f"Failed to create a new index. Reason:\n{e}"
235241
raise CLIError(msg)
236242
click.echo(click.style("Success!", fg="green"))
237243
os.environ["INDEX_NAME"] = index_name
@@ -303,8 +309,8 @@ def upsert(index_name: str,
303309
kb_config = _load_kb_config(config)
304310
try:
305311
kb = KnowledgeBase.from_config(kb_config, index_name=index_name)
306-
except openai.OpenAIError:
307-
raise CLIError(OPENAI_AUTH_ERROR_MSG)
312+
except Exception as e:
313+
raise CLIError(str(e))
308314

309315
try:
310316
kb.connect()

src/canopy_server/app.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,7 @@ async def health_check() -> HealthStatus:
238238

239239
try:
240240
msg = UserMessage(content="This is a health check. Are you alive? Be concise")
241-
await run_in_threadpool(llm.chat_completion, messages=[msg], max_tokens=50)
241+
await run_in_threadpool(llm.chat_completion, messages=[msg], max_tokens=5)
242242
except Exception as e:
243243
err_msg = f"Failed to communicate with {llm.__class__.__name__}"
244244
logger.exception(err_msg)
@@ -281,6 +281,7 @@ async def startup():
281281
_init_logging()
282282
_init_engines()
283283
_init_routes(app)
284+
await health_check()
284285

285286

286287
def _init_routes(app):

0 commit comments

Comments
 (0)