This commit is contained in:
CakeCN
2024-12-03 16:21:19 +08:00
commit 4198ca63b1
975 changed files with 333413 additions and 0 deletions

View File

@@ -0,0 +1,189 @@
# -*- coding: utf-8 -*-
""" Import modules in models package."""
import json
from typing import Union, Type
from loguru import logger
from .config import _ModelConfig
from .model import ModelWrapperBase
from .response import ModelResponse
from .post_model import (
PostAPIModelWrapperBase,
PostAPIChatWrapper,
)
from .openai_model import (
OpenAIWrapperBase,
OpenAIChatWrapper,
OpenAIDALLEWrapper,
OpenAIEmbeddingWrapper,
)
from .dashscope_model import (
DashScopeChatWrapper,
DashScopeImageSynthesisWrapper,
DashScopeTextEmbeddingWrapper,
DashScopeMultiModalWrapper,
)
from .ollama_model import (
OllamaChatWrapper,
OllamaEmbeddingWrapper,
OllamaGenerationWrapper,
)
from .gemini_model import (
GeminiChatWrapper,
GeminiEmbeddingWrapper,
)
from .zhipu_model import (
ZhipuAIChatWrapper,
ZhipuAIEmbeddingWrapper,
)
from .litellm_model import (
LiteLLMChatWrapper,
)
__all__ = [
"ModelWrapperBase",
"ModelResponse",
"PostAPIModelWrapperBase",
"PostAPIChatWrapper",
"OpenAIWrapperBase",
"OpenAIChatWrapper",
"OpenAIDALLEWrapper",
"OpenAIEmbeddingWrapper",
"DashScopeChatWrapper",
"DashScopeImageSynthesisWrapper",
"DashScopeTextEmbeddingWrapper",
"DashScopeMultiModalWrapper",
"OllamaChatWrapper",
"OllamaEmbeddingWrapper",
"OllamaGenerationWrapper",
"GeminiChatWrapper",
"GeminiEmbeddingWrapper",
"ZhipuAIChatWrapper",
"ZhipuAIEmbeddingWrapper",
"LiteLLMChatWrapper",
"load_model_by_config_name",
"load_config_by_name",
"read_model_configs",
"clear_model_configs",
]
_MODEL_CONFIGS: dict[str, dict] = {}
def _get_model_wrapper(model_type: str) -> Type[ModelWrapperBase]:
"""Get the specific type of model wrapper
Args:
model_type (`str`): The model type name.
Returns:
`Type[ModelWrapperBase]`: The corresponding model wrapper class.
"""
wrapper = ModelWrapperBase.get_wrapper(model_type=model_type)
if wrapper is None:
logger.warning(
f"Unsupported model_type [{model_type}],"
"use PostApiModelWrapper instead.",
)
return PostAPIModelWrapperBase
return wrapper
def load_config_by_name(config_name: str) -> Union[dict, None]:
"""Load the model config by name, and return the config dict."""
return _MODEL_CONFIGS.get(config_name, None)
def load_model_by_config_name(config_name: str) -> ModelWrapperBase:
"""Load the model by config name, and return the model wrapper."""
if len(_MODEL_CONFIGS) == 0:
raise ValueError(
"No model configs loaded, please call "
"`read_model_configs` first.",
)
# Find model config by name
if config_name not in _MODEL_CONFIGS:
raise ValueError(
f"Cannot find [{config_name}] in loaded configurations.",
)
config = _MODEL_CONFIGS.get(config_name, None)
if config is None:
raise ValueError(
f"Cannot find [{config_name}] in loaded configurations.",
)
model_type = config.model_type
kwargs = {k: v for k, v in config.items() if k != "model_type"}
return _get_model_wrapper(model_type=model_type)(**kwargs)
def clear_model_configs() -> None:
"""Clear the loaded model configs."""
_MODEL_CONFIGS.clear()
def read_model_configs(
configs: Union[dict, str, list],
clear_existing: bool = False,
) -> None:
"""read model configs from a path or a list of dicts.
Args:
configs (`Union[str, list, dict]`):
The path of the model configs | a config dict | a list of model
configs.
clear_existing (`bool`, defaults to `False`):
Whether to clear the loaded model configs before reading.
Returns:
`dict`:
The model configs.
"""
if clear_existing:
clear_model_configs()
cfgs = None
if isinstance(configs, str):
with open(configs, "r", encoding="utf-8") as f:
cfgs = json.load(f)
if isinstance(configs, dict):
cfgs = [configs]
if isinstance(configs, list):
if not all(isinstance(_, dict) for _ in configs):
raise ValueError(
"The model config unit should be a dict.",
)
cfgs = configs
if cfgs is None:
raise TypeError(
f"Invalid type of model_configs, it could be a dict, a list of "
f"dicts, or a path to a json file (containing a dict or a list "
f"of dicts), but got {type(configs)}",
)
format_configs = _ModelConfig.format_configs(configs=cfgs)
# check if name is unique
for cfg in format_configs:
if cfg.config_name in _MODEL_CONFIGS:
logger.warning(
f"config_name [{cfg.config_name}] already exists.",
)
continue
_MODEL_CONFIGS[cfg.config_name] = cfg
# print the loaded model configs
logger.info(
"Load configs for model wrapper: {}",
", ".join(_MODEL_CONFIGS.keys()),
)

View File

@@ -0,0 +1,59 @@
# -*- coding: utf-8 -*-
"""The model config."""
from typing import Union, Sequence, Any
from loguru import logger
class _ModelConfig(dict):
"""Base class for model config."""
__getattr__ = dict.__getitem__
__setattr__ = dict.__setitem__
def __init__(
self,
config_name: str,
model_type: str = None,
**kwargs: Any,
):
"""Initialize the config with the given arguments, and checking the
type of the arguments.
Args:
config_name (`str`): A unique name of the model config.
model_type (`str`, optional): The class name (or its model type) of
the generated model wrapper. Defaults to None.
Raises:
`ValueError`: If `config_name` is not provided.
"""
if config_name is None:
raise ValueError("The `config_name` field is required for Cfg")
if model_type is None:
logger.warning(
f"`model_type` is not provided in config [{config_name}],"
" use `PostAPIModelWrapperBase` by default.",
)
super().__init__(
config_name=config_name,
model_type=model_type,
**kwargs,
)
@classmethod
def format_configs(
cls,
configs: Union[Sequence[dict], dict],
) -> Sequence:
"""Covert config dicts into a list of _ModelConfig.
Args:
configs (Union[Sequence[dict], dict]): configs in dict format.
Returns:
Sequence[_ModelConfig]: converted ModelConfig list.
"""
if isinstance(configs, dict):
return [_ModelConfig(**configs)]
return [_ModelConfig(**cfg) for cfg in configs]

View File

@@ -0,0 +1,847 @@
# -*- coding: utf-8 -*-
"""Model wrapper for DashScope models"""
import os
from abc import ABC
from http import HTTPStatus
from typing import Any, Union, List, Sequence
from loguru import logger
from ..message import Msg
from ..utils.tools import _convert_to_str, _guess_type_by_extension
try:
import dashscope
except ImportError:
dashscope = None
from .model import ModelWrapperBase, ModelResponse
from ..file_manager import file_manager
class DashScopeWrapperBase(ModelWrapperBase, ABC):
"""The model wrapper for DashScope API."""
def __init__(
self,
config_name: str,
model_name: str = None,
api_key: str = None,
generate_args: dict = None,
**kwargs: Any,
) -> None:
"""Initialize the DashScope wrapper.
Args:
config_name (`str`):
The name of the model config.
model_name (`str`, default `None`):
The name of the model to use in DashScope API.
api_key (`str`, default `None`):
The API key for DashScope API.
generate_args (`dict`, default `None`):
The extra keyword arguments used in DashScope api generation,
e.g. `temperature`, `seed`.
"""
if model_name is None:
model_name = config_name
logger.warning("model_name is not set, use config_name instead.")
super().__init__(config_name=config_name)
if dashscope is None:
raise ImportError(
"Cannot find dashscope package in current python environment.",
)
self.model_name = model_name
self.generate_args = generate_args or {}
self.api_key = api_key
dashscope.api_key = self.api_key
self.max_length = None
# Set monitor accordingly
self._register_default_metrics()
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
raise RuntimeError(
f"Model Wrapper [{type(self).__name__}] doesn't "
f"need to format the input. Please try to use the "
f"model wrapper directly.",
)
class DashScopeChatWrapper(DashScopeWrapperBase):
"""The model wrapper for DashScope's chat API, refer to
https://help.aliyun.com/zh/dashscope/developer-reference/api-details
"""
model_type: str = "dashscope_chat"
deprecated_model_type: str = "tongyi_chat"
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def __call__(
self,
messages: list,
**kwargs: Any,
) -> ModelResponse:
"""Processes a list of messages to construct a payload for the
DashScope API call. It then makes a request to the DashScope API
and returns the response. This method also updates monitoring
metrics based on the API response.
Each message in the 'messages' list can contain text content and
optionally an 'image_urls' key. If 'image_urls' is provided,
it is expected to be a list of strings representing URLs to images.
These URLs will be transformed to a suitable format for the DashScope
API, which might involve converting local file paths to data URIs.
Args:
messages (`list`):
A list of messages to process.
**kwargs (`Any`):
The keyword arguments to DashScope chat completions API,
e.g. `temperature`, `max_tokens`, `top_p`, etc. Please
refer to
https://help.aliyun.com/zh/dashscope/developer-reference/api-details
for more detailed arguments.
Returns:
`ModelResponse`:
The response text in text field, and the raw response in
raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved for
`_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and check
the response generated by the model, which takes the response
as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
The rule of roles in messages for DashScope is very rigid,
for more details, please refer to
https://help.aliyun.com/zh/dashscope/developer-reference/api-details
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: checking messages
if not isinstance(messages, list):
raise ValueError(
"Dashscope `messages` field expected type `list`, "
f"got `{type(messages)}` instead.",
)
if not all("role" in msg and "content" in msg for msg in messages):
raise ValueError(
"Each message in the 'messages' list must contain a 'role' "
"and 'content' key for DashScope API.",
)
# step3: forward to generate response
response = dashscope.Generation.call(
model=self.model_name,
messages=messages,
result_format="message", # set the result to be "message" format.
**kwargs,
)
if response.status_code != HTTPStatus.OK:
error_msg = (
f" Request id: {response.request_id},"
f" Status code: {response.status_code},"
f" error code: {response.code},"
f" error message: {response.message}."
)
raise RuntimeError(error_msg)
# step4: record the api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"messages": messages,
**kwargs,
},
response=response,
)
# step5: update monitor accordingly
# The metric names are unified for comparison
self.update_monitor(
call_counter=1,
prompt_tokens=response.usage.get("input_tokens", 0),
completion_tokens=response.usage.get("output_tokens", 0),
total_tokens=response.usage.get("input_tokens", 0)
+ response.usage.get("output_tokens", 0),
)
# step6: return response
return ModelResponse(
text=response.output["choices"][0]["message"]["content"],
raw=response,
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> List:
"""Format the messages for DashScope Chat API.
In this format function, the input messages are formatted into a
single system messages with format "{name}: {content}" for each
message. Note this strategy maybe not suitable for all scenarios,
and developers are encouraged to implement their own prompt
engineering strategies.
The following is an example:
.. code-block:: python
prompt = model.format(
Msg("system", "You're a helpful assistant", role="system"),
Msg("Bob", "Hi, how can I help you?", role="assistant"),
Msg("user", "What's the date today?", role="user")
)
The prompt will be as follows:
.. code-block:: python
[
{
"role": "system",
"content": "You're a helpful assistant",
}
{
"role": "user",
"content": (
"## Dialogue History\\n"
"Bob: Hi, how can I help you?\\n"
"user: What's the date today?"
)
}
]
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`List[dict]`:
The formatted messages.
"""
# Parse all information into a list of messages
input_msgs = []
for _ in args:
if _ is None:
continue
if isinstance(_, Msg):
input_msgs.append(_)
elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _):
input_msgs.extend(_)
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(_)}.",
)
messages = []
# record dialog history as a list of strings
dialogue = []
for i, unit in enumerate(input_msgs):
if i == 0 and unit.role == "system":
# system prompt
messages.append(
{
"role": unit.role,
"content": _convert_to_str(unit.content),
},
)
else:
# Merge all messages into a dialogue history prompt
dialogue.append(
f"{unit.name}: {_convert_to_str(unit.content)}",
)
dialogue_history = "\n".join(dialogue)
user_content_template = "## Dialogue History\n{dialogue_history}"
messages.append(
{
"role": "user",
"content": user_content_template.format(
dialogue_history=dialogue_history,
),
},
)
return messages
class DashScopeImageSynthesisWrapper(DashScopeWrapperBase):
"""The model wrapper for DashScope Image Synthesis API, refer to
https://help.aliyun.com/zh/dashscope/developer-reference/quick-start-1
"""
model_type: str = "dashscope_image_synthesis"
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("image_count"),
metric_unit="image",
)
def __call__(
self,
prompt: str,
save_local: bool = False,
**kwargs: Any,
) -> ModelResponse:
"""
Args:
prompt (`str`):
The prompt string to generate images from.
save_local: (`bool`, default `False`):
Whether to save the generated images locally, and replace
the returned image url with the local path.
**kwargs (`Any`):
The keyword arguments to DashScope Image Synthesis API,
e.g. `n`, `size`, etc. Please refer to
https://help.aliyun.com/zh/dashscope/developer-reference/api-details-9
for more detailed arguments.
Returns:
`ModelResponse`:
A list of image urls in image_urls field and the
raw response in raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved
for `_response_parse_decorator` to parse and check the
response generated by model wrapper. Their usages are listed
as follows:
- `parse_func` is a callable function used to parse and
check the response generated by the model, which takes
the response as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: forward to generate response
response = dashscope.ImageSynthesis.call(
model=self.model_name,
prompt=prompt,
**kwargs,
)
if response.status_code != HTTPStatus.OK:
error_msg = (
f" Request id: {response.request_id},"
f" Status code: {response.status_code},"
f" error code: {response.code},"
f" error message: {response.message}."
)
raise RuntimeError(error_msg)
# step3: record the model api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"prompt": prompt,
**kwargs,
},
response=response,
)
# step4: update monitor accordingly
self.update_monitor(
call_counter=1,
**response.usage,
)
# step5: return response
images = response.output["results"]
# Get image urls as a list
urls = [_["url"] for _ in images]
if save_local:
# Return local url if save_local is True
urls = [file_manager.save_image(_) for _ in urls]
return ModelResponse(image_urls=urls, raw=response)
class DashScopeTextEmbeddingWrapper(DashScopeWrapperBase):
"""The model wrapper for DashScope Text Embedding API."""
model_type: str = "dashscope_text_embedding"
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def __call__(
self,
texts: Union[list[str], str],
**kwargs: Any,
) -> ModelResponse:
"""Embed the messages with DashScope Text Embedding API.
Args:
texts (`list[str]` or `str`):
The messages used to embed.
**kwargs (`Any`):
The keyword arguments to DashScope Text Embedding API,
e.g. `text_type`. Please refer to
https://help.aliyun.com/zh/dashscope/developer-reference/api-details-15
for more detailed arguments.
Returns:
`ModelResponse`:
A list of embeddings in embedding field and the raw
response in raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved
for `_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and
check the response generated by the model, which takes the
response as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: forward to generate response
response = dashscope.TextEmbedding.call(
input=texts,
model=self.model_name,
**kwargs,
)
if response.status_code != HTTPStatus.OK:
error_msg = (
f" Request id: {response.request_id},"
f" Status code: {response.status_code},"
f" error code: {response.code},"
f" error message: {response.message}."
)
raise RuntimeError(error_msg)
# step3: record the model api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"input": texts,
**kwargs,
},
response=response,
)
# step4: update monitor accordingly
self.update_monitor(
call_counter=1,
**response.usage,
)
# step5: return response
return ModelResponse(
embedding=[_["embedding"] for _ in response.output["embeddings"]],
raw=response,
)
class DashScopeMultiModalWrapper(DashScopeWrapperBase):
"""The model wrapper for DashScope Multimodal API, refer to
https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-vl-api
"""
model_type: str = "dashscope_multimodal"
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def __call__(
self,
messages: list,
**kwargs: Any,
) -> ModelResponse:
"""Model call for DashScope MultiModal API.
Args:
messages (`list`):
A list of messages to process.
**kwargs (`Any`):
The keyword arguments to DashScope MultiModal API,
e.g. `stream`. Please refer to
https://help.aliyun.com/zh/dashscope/developer-reference/tongyi-qianwen-vl-plus-api
for more detailed arguments.
Returns:
`ModelResponse`:
The response text in text field, and the raw response in
raw field.
Note:
If involving image links, then the messages should be of the
following form:
.. code-block:: python
messages = [
{
"role": "system",
"content": [
{"text": "You are a helpful assistant."},
],
},
{
"role": "user",
"content": [
{"text": "What does this picture depict?"},
{"image": "http://example.com/image.jpg"},
],
},
]
Therefore, you should input a list matching the content value
above.
If only involving words, just input them.
`parse_func`, `fault_handler` and `max_retries` are reserved
for `_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and
check the response generated by the model, which takes the
response as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: forward to generate response
response = dashscope.MultiModalConversation.call(
model=self.model_name,
messages=messages,
**kwargs,
)
# Unhandle code path here
# response could be a generator , if stream is yes
# suggest add a check here
if response.status_code != HTTPStatus.OK:
error_msg = (
f" Request id: {response.request_id},"
f" Status code: {response.status_code},"
f" error code: {response.code},"
f" error message: {response.message}."
)
raise RuntimeError(error_msg)
# step3: record the model api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"messages": messages,
**kwargs,
},
response=response,
)
# step4: update monitor accordingly
input_tokens = response.usage.get("input_tokens", 0)
image_tokens = response.usage.get("image_tokens", 0)
audio_tokens = response.usage.get("audio_tokens", 0)
output_tokens = response.usage.get("output_tokens", 0)
self.update_monitor(
call_counter=1,
prompt_tokens=input_tokens,
completion_tokens=output_tokens,
total_tokens=input_tokens
+ output_tokens
+ image_tokens
+ audio_tokens,
)
# step5: return response
content = response.output["choices"][0]["message"]["content"]
if isinstance(content, list):
content = content[0]["text"]
return ModelResponse(
text=content,
raw=response,
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> List:
"""Format the messages for DashScope Multimodal API.
The multimodal API has the following requirements:
- The roles of messages must alternate between "user" and
"assistant".
- The message with the role "system" should be the first message
in the list.
- If the system message exists, then the second message must
have the role "user".
- The last message in the list should have the role "user".
- In each message, more than one figure is allowed.
With the above requirements, we format the messages as follows:
- If the first message is a system message, then we will keep it as
system prompt.
- We merge all messages into a dialogue history prompt in a single
message with the role "user".
- When there are multiple figures in the given messages, we will
attach it to the user message by order. Note if there are
multiple figures, this strategy may cause misunderstanding for
the model. For advanced solutions, developers are encouraged to
implement their own prompt engineering strategies.
The following is an example:
.. code-block:: python
prompt = model.format(
Msg(
"system",
"You're a helpful assistant",
role="system", url="figure1"
),
Msg(
"Bob",
"How about this picture?",
role="assistant", url="figure2"
),
Msg(
"user",
"It's wonderful! How about mine?",
role="user", image="figure3"
)
)
The prompt will be as follows:
.. code-block:: python
[
{
"role": "system",
"content": [
{"text": "You are a helpful assistant"},
{"image": "figure1"}
]
},
{
"role": "user",
"content": [
{"image": "figure2"},
{"image": "figure3"},
{
"text": (
"## Dialogue History\\n"
"Bob: How about this picture?\\n"
"user: It's wonderful! How about mine?"
)
},
]
}
]
Note:
In multimodal API, the url of local files should be prefixed with
"file://", which will be attached in this format function.
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`List[dict]`:
The formatted messages.
"""
# Parse all information into a list of messages
input_msgs = []
for _ in args:
if _ is None:
continue
if isinstance(_, Msg):
input_msgs.append(_)
elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _):
input_msgs.extend(_)
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(_)}.",
)
messages = []
# record dialog history as a list of strings
dialogue = []
image_or_audio_dicts = []
for i, unit in enumerate(input_msgs):
if i == 0 and unit.role == "system":
# system prompt
content = self.convert_url(unit.url)
content.append({"text": _convert_to_str(unit.content)})
messages.append(
{
"role": unit.role,
"content": content,
},
)
else:
# text message
dialogue.append(
f"{unit.name}: {_convert_to_str(unit.content)}",
)
# image and audio
image_or_audio_dicts.extend(self.convert_url(unit.url))
dialogue_history = "\n".join(dialogue)
user_content_template = "## Dialogue History\n{dialogue_history}"
messages.append(
{
"role": "user",
"content": [
# Place the image or audio before the dialogue history
*image_or_audio_dicts,
{
"text": user_content_template.format(
dialogue_history=dialogue_history,
),
},
],
},
)
return messages
def convert_url(self, url: Union[str, Sequence[str], None]) -> List[dict]:
"""Convert the url to the format of DashScope API. Note for local
files, a prefix "file://" will be added.
Args:
url (`Union[str, Sequence[str], None]`):
A string of url of a list of urls to be converted.
Returns:
`List[dict]`:
A list of dictionaries with key as the type of the url
and value as the url. Only "image" and "audio" are supported.
"""
if url is None:
return []
if isinstance(url, str):
url_type = _guess_type_by_extension(url)
if url_type in ["audio", "image"]:
# Add prefix for local files
if os.path.exists(url):
url = "file://" + url
return [{url_type: url}]
else:
# skip unsupported url
logger.warning(
f"Skip unsupported url ({url_type}), "
f"expect image or audio.",
)
return []
elif isinstance(url, list):
dicts = []
for _ in url:
dicts.extend(self.convert_url(_))
return dicts
else:
raise TypeError(
f"Unsupported url type {type(url)}, " f"str or list expected.",
)

View File

@@ -0,0 +1,417 @@
# -*- coding: utf-8 -*-
"""Google Gemini model wrapper."""
import os
from abc import ABC
from collections.abc import Iterable
from typing import Sequence, Union, Any, List
from loguru import logger
from agentscope.message import Msg
from agentscope.models import ModelWrapperBase, ModelResponse
from agentscope.utils.tools import _convert_to_str
try:
import google.generativeai as genai
# This package will be installed when the google-generativeai is installed
import google.ai.generativelanguage as glm
except ImportError:
genai = None
glm = None
class GeminiWrapperBase(ModelWrapperBase, ABC):
"""The base class for Google Gemini model wrapper."""
_generation_method = None
"""The generation method used in `__call__` function, which is used to
filter models in `list_models` function."""
def __init__(
self,
config_name: str,
model_name: str,
api_key: str = None,
**kwargs: Any,
) -> None:
"""Initialize the wrapper for Google Gemini model.
Args:
model_name (`str`):
The name of the model.
api_key (`str`, defaults to `None`):
The api_key for the model. If it is not provided, it will be
loaded from environment variable.
"""
super().__init__(config_name=config_name)
# Test if the required package is installed
if genai is None:
raise ImportError(
"The google-generativeai package is not installed, "
"please install it first.",
)
# Load the api_key from argument or environment variable
api_key = api_key or os.environ.get("GOOGLE_API_KEY")
if api_key is None:
raise ValueError(
"Google api_key must be provided or set as an "
"environment variable.",
)
genai.configure(api_key=api_key, **kwargs)
self.model_name = model_name
self._register_default_metrics()
def _register_default_metrics(self) -> None:
"""Register the default metrics for the model."""
raise NotImplementedError(
"The method `_register_default_metrics` must be implemented.",
)
def list_models(self) -> Sequence:
"""List all available models for this API calling."""
support_models = list(genai.list_models())
if self.generation_method is None:
return support_models
else:
return [
_
for _ in support_models
if self._generation_method in _.supported_generation_methods
]
def __call__(self, *args: Any, **kwargs: Any) -> ModelResponse:
"""Processing input with the model."""
raise NotImplementedError(
f"Model Wrapper [{type(self).__name__}]"
f" is missing the the required `__call__`"
f" method.",
)
class GeminiChatWrapper(GeminiWrapperBase):
"""The wrapper for Google Gemini chat model, e.g. gemini-pro"""
model_type: str = "gemini_chat"
"""The type of the model, which is used in model configuration."""
generation_method = "generateContent"
"""The generation method used in `__call__` function."""
def __init__(
self,
config_name: str,
model_name: str,
api_key: str = None,
**kwargs: Any,
) -> None:
super().__init__(
config_name=config_name,
model_name=model_name,
api_key=api_key,
**kwargs,
)
# Create the generative model
self.model = genai.GenerativeModel(model_name, **kwargs)
def __call__(
self,
contents: Union[Sequence, str],
stream: bool = False,
**kwargs: Any,
) -> ModelResponse:
"""Generate response for the given contents.
Args:
contents (`Union[Sequence, str]`):
The content to generate response.
stream (`bool`, defaults to `False`):
Whether to use stream mode.
**kwargs:
The additional arguments for generating response.
Returns:
`ModelResponse`:
The response text in text field, and the raw response in raw
field.
"""
# step1: checking messages
if isinstance(contents, Iterable):
pass
elif not isinstance(contents, str):
logger.warning(
"The input content is not a string or a list of "
"messages, which may cause unexpected behavior.",
)
# step2: forward to generate response
# TODO: support response in stream mode
response = self.model.generate_content(
contents,
stream=stream,
**kwargs,
)
# step3: Check for candidates and handle accordingly
if (
not response.candidates[0].content
or not response.candidates[0].content.parts
or not response.candidates[0].content.parts[0].text
):
# If we cannot get the response text from the model
finish_reason = response.candidates[0].finish_reason
reasons = glm.Candidate.FinishReason
if finish_reason == reasons.STOP:
error_info = (
"Natural stop point of the model or provided stop "
"sequence."
)
elif finish_reason == reasons.MAX_TOKENS:
error_info = (
"The maximum number of tokens as specified in the request "
"was reached."
)
elif finish_reason == reasons.SAFETY:
error_info = (
"The candidate content was flagged for safety reasons."
)
elif finish_reason == reasons.RECITATION:
error_info = (
"The candidate content was flagged for recitation reasons."
)
elif finish_reason in [
reasons.FINISH_REASON_UNSPECIFIED,
reasons.OTHER,
]:
error_info = "Unknown error."
else:
error_info = "No information provided from Gemini API."
raise ValueError(
"The Google Gemini API failed to generate text response with "
f"the following finish reason: {error_info}\n"
f"YOUR INPUT: {contents}\n"
f"RAW RESPONSE FROM GEMINI API: {response}\n",
)
# step4: record the api invocation if needed
self._save_model_invocation(
arguments={
"contents": contents,
"stream": stream,
**kwargs,
},
response=str(response),
)
# step5: update monitor accordingly
token_prompt = self.model.count_tokens(contents).total_tokens
token_response = self.model.count_tokens(response.text).total_tokens
self.update_monitor(
call_counter=1,
completion_tokens=token_response,
prompt_tokens=token_prompt,
total_tokens=token_prompt + token_response,
)
# step6: return response
return ModelResponse(
text=response.text,
raw=response,
)
def _register_default_metrics(self) -> None:
"""Register the default metrics for the model."""
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> List[dict]:
"""This function provide a basic prompting strategy for Gemini Chat
API in multi-party conversation, which combines all input into a
single string, and wrap it into a user message.
We make the above decision based on the following constraints of the
Gemini generate API:
1. In Gemini `generate_content` API, the `role` field must be either
`user` or `model`.
2. If we pass a list of messages to the `generate_content` API,
the `user` role must speak in the beginning and end of the
messages, and `user` and `model` must alternative. This prevents
us to build a multi-party conversations, where `model` may keep
speaking in different names.
The above information is updated to 2024/03/21. More information
about the Gemini `generate_content` API can be found in
https://cloud.google.com/vertex-ai/generative-ai/docs/model-reference/gemini
Based on the above considerations, we decide to combine all messages
into a single user message. This is a simple and straightforward
strategy, if you have any better ideas, pull request and
discussion are welcome in our GitHub repository
https://github.com/agentscope/agentscope!
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`List[dict]`:
A list with one user message.
"""
input_msgs = []
for _ in args:
if _ is None:
continue
if isinstance(_, Msg):
input_msgs.append(_)
elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _):
input_msgs.extend(_)
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(_)}.",
)
# record dialog history as a list of strings
sys_prompt = None
dialogue = []
for i, unit in enumerate(input_msgs):
if i == 0 and unit.role == "system":
# system prompt
sys_prompt = _convert_to_str(unit.content)
else:
# Merge all messages into a dialogue history prompt
dialogue.append(
f"{unit.name}: {_convert_to_str(unit.content)}",
)
dialogue_history = "\n".join(dialogue)
if sys_prompt is None:
user_content_template = "## Dialogue History\n{dialogue_history}"
else:
user_content_template = (
"{sys_prompt}\n"
"\n"
"## Dialogue History\n"
"{dialogue_history}"
)
messages = [
{
"role": "user",
"parts": [
user_content_template.format(
sys_prompt=sys_prompt,
dialogue_history=dialogue_history,
),
],
},
]
return messages
class GeminiEmbeddingWrapper(GeminiWrapperBase):
"""The wrapper for Google Gemini embedding model,
e.g. models/embedding-001"""
model_type: str = "gemini_embedding"
"""The type of the model, which is used in model configuration."""
_generation_method = "embedContent"
"""The generation method used in `__call__` function."""
def __call__(
self,
content: Union[Sequence[Msg], str],
task_type: str = None,
title: str = None,
**kwargs: Any,
) -> ModelResponse:
"""Generate embedding for the given content. More detailed information
please refer to
https://ai.google.dev/tutorials/python_quickstart#use_embeddings
Args:
content (`Union[Sequence[Msg], str]`):
The content to generate embedding.
task_type (`str`, defaults to `None`):
The type of the task.
title (`str`, defaults to `None`):
The title of the content.
**kwargs:
The additional arguments for generating embedding.
Returns:
`ModelResponse`:
The response embedding in embedding field, and the raw response
in raw field.
"""
# step1: forward to generate response
response = genai.embed_content(
model=self.model_name,
content=content,
task_type=task_type,
title=title,
**kwargs,
)
# step2: record the api invocation if needed
self._save_model_invocation(
arguments={
"content": content,
"task_type": task_type,
"title": title,
**kwargs,
},
response=response,
)
# TODO: Up to 2023/03/11, the embedding model doesn't support to
# count tokens.
# step3: update monitor accordingly
self.update_monitor(call_counter=1)
return ModelResponse(
raw=response,
embedding=response["embedding"],
)
def _register_default_metrics(self) -> None:
"""Register the default metrics for the model."""
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)

View File

@@ -0,0 +1,252 @@
# -*- coding: utf-8 -*-
"""Model wrapper based on litellm https://docs.litellm.ai/docs/"""
from abc import ABC
from typing import Union, Any, List, Sequence
from loguru import logger
from .model import ModelWrapperBase, ModelResponse
from ..message import Msg
from ..utils.tools import _convert_to_str
try:
import litellm
except ImportError:
litellm = None
class LiteLLMWrapperBase(ModelWrapperBase, ABC):
"""The model wrapper based on LiteLLM API."""
def __init__(
self,
config_name: str,
model_name: str = None,
generate_args: dict = None,
**kwargs: Any,
) -> None:
"""
To use the LiteLLM wrapper, environent variables must be set.
Different model_name could be using different environment variables.
For example:
- for model_name: "gpt-3.5-turbo", you need to set "OPENAI_API_KEY"
```
os.environ["OPENAI_API_KEY"] = "your-api-key"
```
- for model_name: "claude-2", you need to set "ANTHROPIC_API_KEY"
- for Azure OpenAI, you need to set "AZURE_API_KEY",
"AZURE_API_BASE", "AZURE_API_VERSION"
You should refer to the docs in https://docs.litellm.ai/docs/ .
Args:
config_name (`str`):
The name of the model config.
model_name (`str`, default `None`):
The name of the model to use in OpenAI API.
generate_args (`dict`, default `None`):
The extra keyword arguments used in litellm api generation,
e.g. `temperature`, `seed`.
For generate_args, please refer to
https://docs.litellm.ai/docs/completion/input
for more detailes.
"""
if model_name is None:
model_name = config_name
logger.warning("model_name is not set, use config_name instead.")
super().__init__(config_name=config_name)
if litellm is None:
raise ImportError(
"Cannot import litellm package in current python environment."
"You should try:"
"1. Install litellm by `pip install litellm`"
"2. If you still have import error, you should try to "
"update the openai to higher version, e.g. "
"by runing `pip install openai==1.25.1",
)
self.model_name = model_name
self.generate_args = generate_args or {}
self._register_default_metrics()
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
raise RuntimeError(
f"Model Wrapper [{type(self).__name__}] doesn't "
f"need to format the input. Please try to use the "
f"model wrapper directly.",
)
class LiteLLMChatWrapper(LiteLLMWrapperBase):
"""The model wrapper based on litellm chat API.
To use the LiteLLM wrapper, environent variables must be set.
Different model_name could be using different environment variables.
For example:
- for model_name: "gpt-3.5-turbo", you need to set "OPENAI_API_KEY"
```
os.environ["OPENAI_API_KEY"] = "your-api-key"
```
- for model_name: "claude-2", you need to set "ANTHROPIC_API_KEY"
- for Azure OpenAI, you need to set "AZURE_API_KEY",
"AZURE_API_BASE", "AZURE_API_VERSION"
You should refer to the docs in https://docs.litellm.ai/docs/ .
"""
model_type: str = "litellm_chat"
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def __call__(
self,
messages: list,
**kwargs: Any,
) -> ModelResponse:
"""
Args:
messages (`list`):
A list of messages to process.
**kwargs (`Any`):
The keyword arguments to litellm chat completions API,
e.g. `temperature`, `max_tokens`, `top_p`, etc. Please refer to
https://docs.litellm.ai/docs/completion/input
for more detailed arguments.
Returns:
`ModelResponse`:
The response text in text field, and the raw response in
raw field.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: checking messages
if not isinstance(messages, list):
raise ValueError(
"LiteLLM `messages` field expected type `list`, "
f"got `{type(messages)}` instead.",
)
if not all("role" in msg and "content" in msg for msg in messages):
raise ValueError(
"Each message in the 'messages' list must contain a 'role' "
"and 'content' key for LiteLLM API.",
)
# step3: forward to generate response
response = litellm.completion(
model=self.model_name,
messages=messages,
**kwargs,
)
# step4: record the api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"messages": messages,
**kwargs,
},
response=response.model_dump(),
)
# step5: update monitor accordingly
self.update_monitor(call_counter=1, **response.usage.model_dump())
# step6: return response
return ModelResponse(
text=response.choices[0].message.content,
raw=response.model_dump(),
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> List[dict]:
"""Format the input string and dictionary into the unified format.
Note that the format function might not be the optimal way to construct
prompt for every model, but a common way to do so.
Developers are encouraged to implement their own prompt
engineering strategies if have strong performance concerns.
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`List[dict]`:
The formatted messages in the format that anthropic Chat API
required.
"""
# Parse all information into a list of messages
input_msgs = []
for _ in args:
if _ is None:
continue
if isinstance(_, Msg):
input_msgs.append(_)
elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _):
input_msgs.extend(_)
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(_)}.",
)
# record dialog history as a list of strings
system_content_template = []
dialogue = []
for i, unit in enumerate(input_msgs):
if i == 0 and unit.role == "system":
# system prompt
system_prompt = _convert_to_str(unit.content)
if not system_prompt.endswith("\n"):
system_prompt += "\n"
system_content_template.append(system_prompt)
else:
# Merge all messages into a dialogue history prompt
dialogue.append(
f"{unit.name}: {_convert_to_str(unit.content)}",
)
if len(dialogue) != 0:
dialogue_history = "\n".join(dialogue)
system_content_template.extend(
["## Dialogue History", dialogue_history],
)
system_content = "\n".join(system_content_template)
messages = [
{
"role": "user",
"content": system_content,
},
]
return messages

View File

@@ -0,0 +1,305 @@
# -*- coding: utf-8 -*-
"""The configuration file should contain one or a list of model configs,
and each model config should follow the following format.
.. code-block:: python
{
"config_name": "{config_name}",
"model_type": "openai_chat" | "post_api" | ...,
...
}
After that, you can specify model by {config_name}.
Note:
The parameters for different types of models are different. For OpenAI API,
the format is:
.. code-block:: python
{
"config_name": "{id of your model}",
"model_type": "openai_chat",
"model_name": "{model_name_for_openai, e.g. gpt-3.5-turbo}",
"api_key": "{your_api_key}",
"organization": "{your_organization, if needed}",
"client_args": {
# ...
},
"generate_args": {
# ...
}
}
For Post API, toking huggingface inference API as an example, its format
is:
.. code-block:: python
{
"config_name": "{config_name}",
"model_type": "post_api",
"api_url": "{api_url}",
"headers": {"Authorization": "Bearer {API_TOKEN}"},
"max_length": {max_length_of_model},
"timeout": {timeout},
"max_retries": {max_retries},
"generate_args": {
"temperature": 0.5,
# ...
}
}
"""
from __future__ import annotations
import inspect
import time
from abc import ABCMeta
from functools import wraps
from typing import Sequence, Any, Callable, Union, List, Type
from loguru import logger
from agentscope.utils import QuotaExceededError
from .response import ModelResponse
from ..exception import ResponseParsingError
from ..file_manager import file_manager
from ..message import Msg
from ..utils import MonitorFactory
from ..utils.monitor import get_full_name
from ..utils.tools import _get_timestamp
from ..constants import _DEFAULT_MAX_RETRIES
from ..constants import _DEFAULT_RETRY_INTERVAL
def _response_parse_decorator(
model_call: Callable,
) -> Callable:
"""A decorator for parsing the response of model call. It will take
`parse_func`, `fault_handler` and `max_retries` as arguments. The
detailed process is as follows:
1. If `parse_func` is provided, then the response will be parsed first.
2. If the parsing fails (throws an exception), then response generation
will be repeated for `max_retries` times and parsed again.
3. After `max_retries` times, if the parsing still fails, then if
`fault_handler` is provided, the response will be processed by
`fault_handler`.
"""
# check if the decorated `model_call` function uses the default
# arguments of this decorator.
parameters = inspect.signature(model_call).parameters
for name in parameters.keys():
if name in ["parse_func", "max_retries"]:
logger.warning(
f"The argument {name} is used by the decorator, "
f"which will not be passed to the model call "
f"function.",
)
@wraps(model_call)
def checking_wrapper(self: Any, *args: Any, **kwargs: Any) -> dict:
# Step1: Extract parse_func and fault_handler
parse_func = kwargs.pop("parse_func", None)
fault_handler = kwargs.pop("fault_handler", None)
max_retries = kwargs.pop("max_retries", None) or _DEFAULT_MAX_RETRIES
# Step2: Call the model and parse the response
# Return the response directly if parse_func is not provided
if parse_func is None:
return model_call(self, *args, **kwargs)
# Otherwise, try to parse the response
for itr in range(1, max_retries + 1):
# Call the model
response = model_call(self, *args, **kwargs)
# Parse the response if needed
try:
return parse_func(response)
except ResponseParsingError as e:
if itr < max_retries:
logger.warning(
f"Fail to parse response ({itr}/{max_retries}):\n"
f"{response}.\n"
f"{e.__class__.__name__}: {e}",
)
time.sleep(_DEFAULT_RETRY_INTERVAL * itr)
else:
if fault_handler is not None and callable(fault_handler):
return fault_handler(response)
else:
raise
return {}
return checking_wrapper
class _ModelWrapperMeta(ABCMeta):
"""A meta call to replace the model wrapper's __call__ function with
wrapper about error handling."""
def __new__(mcs, name: Any, bases: Any, attrs: Any) -> Any:
if "__call__" in attrs:
attrs["__call__"] = _response_parse_decorator(attrs["__call__"])
return super().__new__(mcs, name, bases, attrs)
def __init__(cls, name: Any, bases: Any, attrs: Any) -> None:
if not hasattr(cls, "_registry"):
cls._registry = {}
cls._type_registry = {}
cls._deprecated_type_registry = {}
else:
cls._registry[name] = cls
if hasattr(cls, "model_type"):
cls._type_registry[cls.model_type] = cls
if hasattr(cls, "deprecated_model_type"):
cls._deprecated_type_registry[
cls.deprecated_model_type
] = cls
super().__init__(name, bases, attrs)
class ModelWrapperBase(metaclass=_ModelWrapperMeta):
"""The base class for model wrapper."""
model_type: str
"""The type of the model wrapper, which is to identify the model wrapper
class in model configuration."""
config_name: str
"""The name of the model configuration."""
model_name: str
"""The name of the model, which is used in model api calling."""
def __init__(
self, # pylint: disable=W0613
config_name: str,
**kwargs: Any,
) -> None:
"""Base class for model wrapper.
All model wrappers should inherit this class and implement the
`__call__` function.
Args:
config_name (`str`):
The id of the model, which is used to extract configuration
from the config file.
"""
self.monitor = MonitorFactory.get_monitor()
self.config_name = config_name
logger.info(f"Initialize model by configuration [{config_name}]")
@classmethod
def get_wrapper(cls, model_type: str) -> Type[ModelWrapperBase]:
"""Get the specific model wrapper"""
if model_type in cls._type_registry:
return cls._type_registry[model_type] # type: ignore[return-value]
elif model_type in cls._registry:
return cls._registry[model_type] # type: ignore[return-value]
elif model_type in cls._deprecated_type_registry:
deprecated_cls = cls._deprecated_type_registry[model_type]
logger.warning(
f"Model type [{model_type}] will be deprecated in future "
f"releases, please use [{deprecated_cls.model_type}] instead.",
)
return deprecated_cls # type: ignore[return-value]
else:
return None # type: ignore[return-value]
def __call__(self, *args: Any, **kwargs: Any) -> ModelResponse:
"""Processing input with the model."""
raise NotImplementedError(
f"Model Wrapper [{type(self).__name__}]"
f" is missing the required `__call__`"
f" method.",
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
"""Format the input string or dict into the format that the model
API required."""
raise NotImplementedError(
f"Model Wrapper [{type(self).__name__}]"
f" is missing the required `format` method",
)
def _save_model_invocation(
self,
arguments: dict,
response: Any,
) -> None:
"""Save model invocation."""
model_class = self.__class__.__name__
timestamp = _get_timestamp("%Y%m%d-%H%M%S")
invocation_record = {
"model_class": model_class,
"timestamp": timestamp,
"arguments": arguments,
"response": response,
}
file_manager.save_api_invocation(
f"model_{model_class}_{timestamp}",
invocation_record,
)
def _register_budget(self, model_name: str, budget: float) -> None:
"""Register the budget of the model by model_name."""
self.monitor.register_budget(
model_name=model_name,
value=budget,
prefix=model_name,
)
def _register_default_metrics(self) -> None:
"""Register metrics to the monitor."""
def _metric(self, metric_name: str) -> str:
"""Add the class name and model name as prefix to the metric name.
Args:
metric_name (`str`):
The metric name.
Returns:
`str`: Metric name of this wrapper.
"""
if hasattr(self, "model_name"):
return get_full_name(name=metric_name, prefix=self.model_name)
else:
return get_full_name(name=metric_name)
def update_monitor(self, **kwargs: Any) -> None:
"""Update the monitor with the given values.
Args:
kwargs (`dict`):
The values to be updated to the monitor.
"""
if hasattr(self, "model_name"):
prefix = self.model_name
else:
prefix = None
try:
self.monitor.update(
kwargs,
prefix=prefix,
)
except QuotaExceededError as e:
logger.error(e.message)

View File

@@ -0,0 +1,513 @@
# -*- coding: utf-8 -*-
"""Model wrapper for Ollama models."""
from abc import ABC
from typing import Sequence, Any, Optional, List, Union
from agentscope.message import Msg
from agentscope.models import ModelWrapperBase, ModelResponse
from agentscope.utils.tools import _convert_to_str
try:
import ollama
except ImportError:
ollama = None
class OllamaWrapperBase(ModelWrapperBase, ABC):
"""The base class for Ollama model wrappers.
To use Ollama API, please
1. First install ollama server from https://ollama.com/download and
start the server
2. Pull the model by `ollama pull {model_name}` in terminal
After that, you can use the ollama API.
"""
model_type: str
"""The type of the model wrapper, which is to identify the model wrapper
class in model configuration."""
model_name: str
"""The model name used in ollama API."""
options: dict
"""A dict contains the options for ollama generation API,
e.g. {"temperature": 0, "seed": 123}"""
keep_alive: str
"""Controls how long the model will stay loaded into memory following
the request."""
def __init__(
self,
config_name: str,
model_name: str,
options: dict = None,
keep_alive: str = "5m",
**kwargs: Any,
) -> None:
"""Initialize the model wrapper for Ollama API.
Args:
model_name (`str`):
The model name used in ollama API.
options (`dict`, default `None`):
The extra keyword arguments used in Ollama api generation,
e.g. `{"temperature": 0., "seed": 123}`.
keep_alive (`str`, default `5m`):
Controls how long the model will stay loaded into memory
following the request.
"""
super().__init__(config_name=config_name)
self.model_name = model_name
self.options = options
self.keep_alive = keep_alive
self._register_default_metrics()
class OllamaChatWrapper(OllamaWrapperBase):
"""The model wrapper for Ollama chat API."""
model_type: str = "ollama_chat"
def __call__(
self,
messages: Sequence[dict],
options: Optional[dict] = None,
keep_alive: Optional[str] = None,
**kwargs: Any,
) -> ModelResponse:
"""Generate response from the given messages.
Args:
messages (`Sequence[dict]`):
A list of messages, each message is a dict contains the `role`
and `content` of the message.
options (`dict`, default `None`):
The extra arguments used in ollama chat API, which takes
effect only on this call, and will be merged with the
`options` input in the constructor,
e.g. `{"temperature": 0., "seed": 123}`.
keep_alive (`str`, default `None`):
How long the model will stay loaded into memory following
the request, which takes effect only on this call, and will
override the `keep_alive` input in the constructor.
Returns:
`ModelResponse`:
The response text in `text` field, and the raw response in
`raw` field.
"""
# step1: prepare parameters accordingly
if options is None:
options = self.options
else:
options = {**self.options, **options}
keep_alive = keep_alive or self.keep_alive
# step2: forward to generate response
response = ollama.chat(
model=self.model_name,
messages=messages,
options=options,
keep_alive=keep_alive,
**kwargs,
)
# step2: record the api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"messages": messages,
"options": options,
"keep_alive": keep_alive,
**kwargs,
},
response=response,
)
# step3: monitor the response
self.update_monitor(
call_counter=1,
prompt_tokens=response.get("prompt_eval_count", 0),
completion_tokens=response.get("eval_count", 0),
total_tokens=response.get("prompt_eval_count", 0)
+ response.get("eval_count", 0),
)
# step4: return response
return ModelResponse(
text=response["message"]["content"],
raw=response,
)
def _register_default_metrics(self) -> None:
"""Register metrics to the monitor."""
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="tokens",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> List[dict]:
"""Format the messages for ollama Chat API.
All messages will be formatted into a single system message with
system prompt and dialogue history.
Note:
1. This strategy maybe not suitable for all scenarios,
and developers are encouraged to implement their own prompt
engineering strategies.
2. For ollama chat api, the content field shouldn't be empty string.
Example:
.. code-block:: python
prompt = model.format(
Msg("system", "You're a helpful assistant", role="system"),
Msg("Bob", "Hi, how can I help you?", role="assistant"),
Msg("user", "What's the date today?", role="user")
)
The prompt will be as follows:
.. code-block:: python
[
{
"role": "user",
"content": (
"You're a helpful assistant\\n\\n"
"## Dialogue History\\n"
"Bob: Hi, how can I help you?\\n"
"user: What's the date today?"
)
}
]
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`List[dict]`:
The formatted messages.
"""
# Parse all information into a list of messages
input_msgs = []
for _ in args:
if _ is None:
continue
if isinstance(_, Msg):
input_msgs.append(_)
elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _):
input_msgs.extend(_)
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(_)}.",
)
# record dialog history as a list of strings
system_content_template = []
dialogue = []
# TODO: here we default the url links to images
images = []
for i, unit in enumerate(input_msgs):
if i == 0 and unit.role == "system":
# system prompt
system_prompt = _convert_to_str(unit.content)
if not system_prompt.endswith("\n"):
system_prompt += "\n"
system_content_template.append(system_prompt)
else:
# Merge all messages into a dialogue history prompt
dialogue.append(
f"{unit.name}: {_convert_to_str(unit.content)}",
)
if unit.url is not None:
images.append(unit.url)
if len(dialogue) != 0:
dialogue_history = "\n".join(dialogue)
system_content_template.extend(
["## Dialogue History", dialogue_history],
)
system_content = "\n".join(system_content_template)
system_message = {
"role": "system",
"content": system_content,
}
if len(images) != 0:
system_message["images"] = images
return [system_message]
class OllamaEmbeddingWrapper(OllamaWrapperBase):
"""The model wrapper for Ollama embedding API."""
model_type: str = "ollama_embedding"
def __call__(
self,
prompt: str,
options: Optional[dict] = None,
keep_alive: Optional[str] = None,
**kwargs: Any,
) -> ModelResponse:
"""Generate embedding from the given prompt.
Args:
prompt (`str`):
The prompt to generate response.
options (`dict`, default `None`):
The extra arguments used in ollama embedding API, which takes
effect only on this call, and will be merged with the
`options` input in the constructor,
e.g. `{"temperature": 0., "seed": 123}`.
keep_alive (`str`, default `None`):
How long the model will stay loaded into memory following
the request, which takes effect only on this call, and will
override the `keep_alive` input in the constructor.
Returns:
`ModelResponse`:
The response embedding in `embedding` field, and the raw
response in `raw` field.
"""
# step1: prepare parameters accordingly
if options is None:
options = self.options
else:
options = {**self.options, **options}
keep_alive = keep_alive or self.keep_alive
# step2: forward to generate response
response = ollama.embeddings(
model=self.model_name,
prompt=prompt,
options=options,
keep_alive=keep_alive,
**kwargs,
)
# step3: record the api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"prompt": prompt,
"options": options,
"keep_alive": keep_alive,
**kwargs,
},
response=response,
)
# step4: monitor the response
self.update_monitor(call_counter=1)
# step5: return response
return ModelResponse(
embedding=[response["embedding"]],
raw=response,
)
def _register_default_metrics(self) -> None:
"""Register metrics to the monitor."""
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
raise RuntimeError(
f"Model Wrapper [{type(self).__name__}] doesn't "
f"need to format the input. Please try to use the "
f"model wrapper directly.",
)
class OllamaGenerationWrapper(OllamaWrapperBase):
"""The model wrapper for Ollama generation API."""
model_type: str = "ollama_generate"
def __call__(
self,
prompt: str,
options: Optional[dict] = None,
keep_alive: Optional[str] = None,
**kwargs: Any,
) -> ModelResponse:
"""Generate response from the given prompt.
Args:
prompt (`str`):
The prompt to generate response.
options (`dict`, default `None`):
The extra arguments used in ollama generation API, which takes
effect only on this call, and will be merged with the
`options` input in the constructor,
e.g. `{"temperature": 0., "seed": 123}`.
keep_alive (`str`, default `None`):
How long the model will stay loaded into memory following
the request, which takes effect only on this call, and will
override the `keep_alive` input in the constructor.
Returns:
`ModelResponse`:
The response text in `text` field, and the raw response in
`raw` field.
"""
# step1: prepare parameters accordingly
if options is None:
options = self.options
else:
options = {**self.options, **options}
keep_alive = keep_alive or self.keep_alive
# step2: forward to generate response
response = ollama.generate(
model=self.model_name,
prompt=prompt,
options=options,
keep_alive=keep_alive,
)
# step3: record the api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"prompt": prompt,
"options": options,
"keep_alive": keep_alive,
**kwargs,
},
response=response,
)
# step4: monitor the response
self.update_monitor(
call_counter=1,
prompt_tokens=response.get("prompt_eval_count", 0),
completion_tokens=response.get("eval_count", 0),
total_tokens=response.get("prompt_eval_count", 0)
+ response.get("eval_count", 0),
)
# step5: return response
return ModelResponse(
text=response["response"],
raw=response,
)
def _register_default_metrics(self) -> None:
"""Register metrics to the monitor."""
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="tokens",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def format(self, *args: Union[Msg, Sequence[Msg]]) -> str:
"""Forward the input to the model.
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`str`:
The formatted string prompt.
"""
input_msgs = []
for _ in args:
if _ is None:
continue
if isinstance(_, Msg):
input_msgs.append(_)
elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _):
input_msgs.extend(_)
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(_)}.",
)
sys_prompt = None
dialogue = []
for i, unit in enumerate(input_msgs):
if i == 0 and unit.role == "system":
# system prompt
sys_prompt = _convert_to_str(unit.content)
else:
# Merge all messages into a dialogue history prompt
dialogue.append(
f"{unit.name}: {_convert_to_str(unit.content)}",
)
dialogue_history = "\n".join(dialogue)
if sys_prompt is None:
prompt_template = "## Dialogue History\n{dialogue_history}"
else:
prompt_template = (
"{system_prompt}\n"
"\n"
"## Dialogue History\n"
"{dialogue_history}"
)
return prompt_template.format(
system_prompt=sys_prompt,
dialogue_history=dialogue_history,
)

View File

@@ -0,0 +1,528 @@
# -*- coding: utf-8 -*-
"""Model wrapper for OpenAI models"""
from abc import ABC
from typing import Union, Any, List, Sequence, Dict
from loguru import logger
from .model import ModelWrapperBase, ModelResponse
from ..file_manager import file_manager
from ..message import Msg
from ..utils.tools import _convert_to_str, _to_openai_image_url
try:
import openai
except ImportError:
openai = None
from ..utils.token_utils import get_openai_max_length
from ..constants import _DEFAULT_API_BUDGET
class OpenAIWrapperBase(ModelWrapperBase, ABC):
"""The model wrapper for OpenAI API."""
def __init__(
self,
config_name: str,
model_name: str = None,
api_key: str = None,
organization: str = None,
client_args: dict = None,
generate_args: dict = None,
budget: float = _DEFAULT_API_BUDGET,
**kwargs: Any,
) -> None:
"""Initialize the openai client.
Args:
config_name (`str`):
The name of the model config.
model_name (`str`, default `None`):
The name of the model to use in OpenAI API.
api_key (`str`, default `None`):
The API key for OpenAI API. If not specified, it will
be read from the environment variable `OPENAI_API_KEY`.
organization (`str`, default `None`):
The organization ID for OpenAI API. If not specified, it will
be read from the environment variable `OPENAI_ORGANIZATION`.
client_args (`dict`, default `None`):
The extra keyword arguments to initialize the OpenAI client.
generate_args (`dict`, default `None`):
The extra keyword arguments used in openai api generation,
e.g. `temperature`, `seed`.
budget (`float`, default `None`):
The total budget using this model. Set to `None` means no
limit.
"""
if model_name is None:
model_name = config_name
logger.warning("model_name is not set, use config_name instead.")
super().__init__(config_name=config_name)
if openai is None:
raise ImportError(
"Cannot find openai package in current python environment.",
)
self.model_name = model_name
self.generate_args = generate_args or {}
self.client = openai.OpenAI(
api_key=api_key,
organization=organization,
**(client_args or {}),
)
# Set the max length of OpenAI model
try:
self.max_length = get_openai_max_length(self.model_name)
except Exception as e:
logger.warning(
f"fail to get max_length for {self.model_name}: " f"{e}",
)
self.max_length = None
# Set monitor accordingly
self._register_budget(model_name, budget)
self._register_default_metrics()
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
raise RuntimeError(
f"Model Wrapper [{type(self).__name__}] doesn't "
f"need to format the input. Please try to use the "
f"model wrapper directly.",
)
class OpenAIChatWrapper(OpenAIWrapperBase):
"""The model wrapper for OpenAI's chat API."""
model_type: str = "openai_chat"
deprecated_model_type: str = "openai"
substrings_in_vision_models_names = ["gpt-4-turbo", "vision", "gpt-4o"]
"""The substrings in the model names of vision models."""
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def __call__(
self,
messages: list,
**kwargs: Any,
) -> ModelResponse:
"""Processes a list of messages to construct a payload for the OpenAI
API call. It then makes a request to the OpenAI API and returns the
response. This method also updates monitoring metrics based on the
API response.
Each message in the 'messages' list can contain text content and
optionally an 'image_urls' key. If 'image_urls' is provided,
it is expected to be a list of strings representing URLs to images.
These URLs will be transformed to a suitable format for the OpenAI
API, which might involve converting local file paths to data URIs.
Args:
messages (`list`):
A list of messages to process.
**kwargs (`Any`):
The keyword arguments to OpenAI chat completions API,
e.g. `temperature`, `max_tokens`, `top_p`, etc. Please refer to
https://platform.openai.com/docs/api-reference/chat/create
for more detailed arguments.
Returns:
`ModelResponse`:
The response text in text field, and the raw response in
raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved for
`_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and check
the response generated by the model, which takes the response
as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: checking messages
if not isinstance(messages, list):
raise ValueError(
"OpenAI `messages` field expected type `list`, "
f"got `{type(messages)}` instead.",
)
if not all("role" in msg and "content" in msg for msg in messages):
raise ValueError(
"Each message in the 'messages' list must contain a 'role' "
"and 'content' key for OpenAI API.",
)
# step3: forward to generate response
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
**kwargs,
)
print(response)
# step4: record the api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"messages": messages,
**kwargs,
},
response=response.model_dump(),
)
# step5: update monitor accordingly
#self.update_monitor(call_counter=1, **response.usage.model_dump())
# step6: return response
return ModelResponse(
text=response.choices[0].message.content,
raw=response.model_dump(),
)
def _format_msg_with_url(
self,
msg: Msg,
) -> Dict:
"""Format a message with image urls into openai chat format.
This format method is used for gpt-4o, gpt-4-turbo, gpt-4-vision and
other vision models.
"""
# Check if the model is a vision model
if not any(
_ in self.model_name
for _ in self.substrings_in_vision_models_names
):
logger.warning(
f"The model {self.model_name} is not a vision model. "
f"Skip the url in the message.",
)
return {
"role": msg.role,
"name": msg.name,
"content": _convert_to_str(msg.content),
}
# Put all urls into a list
urls = [msg.url] if isinstance(msg.url, str) else msg.url
# Check if the url refers to an image
checked_urls = []
for url in urls:
try:
checked_urls.append(_to_openai_image_url(url))
except TypeError:
logger.warning(
f"The url {url} is not a valid image url for "
f"OpenAI Chat API, skipped.",
)
if len(checked_urls) == 0:
# If no valid image url is provided, return the normal message dict
return {
"role": msg.role,
"name": msg.name,
"content": _convert_to_str(msg.content),
}
else:
# otherwise, use the vision format message
returned_msg = {
"role": msg.role,
"name": msg.name,
"content": [
{
"type": "text",
"text": _convert_to_str(msg.content),
},
],
}
image_dicts = [
{
"type": "image_url",
"image_url": {
"url": _,
},
}
for _ in checked_urls
]
returned_msg["content"].extend(image_dicts)
return returned_msg
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> List[dict]:
"""Format the input string and dictionary into the format that
OpenAI Chat API required.
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`List[dict]`:
The formatted messages in the format that OpenAI Chat API
required.
"""
messages = []
for arg in args:
if arg is None:
continue
if isinstance(arg, Msg):
if arg.url is not None:
messages.append(self._format_msg_with_url(arg))
else:
messages.append(
{
"role": arg.role,
"name": arg.name,
"content": _convert_to_str(arg.content),
},
)
elif isinstance(arg, list):
messages.extend(self.format(*arg))
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(arg)}.",
)
return messages
class OpenAIDALLEWrapper(OpenAIWrapperBase):
"""The model wrapper for OpenAI's DALL·E API."""
model_type: str = "openai_dall_e"
_resolutions: list = [
"1792*1024",
"1024*1792",
"1024*1024",
"512*512",
"256*256",
]
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
for resolution in self._resolutions:
self.monitor.register(
self._metric(resolution),
metric_unit="image",
)
def __call__(
self,
prompt: str,
save_local: bool = False,
**kwargs: Any,
) -> ModelResponse:
"""
Args:
prompt (`str`):
The prompt string to generate images from.
save_local: (`bool`, default `False`):
Whether to save the generated images locally, and replace
the returned image url with the local path.
**kwargs (`Any`):
The keyword arguments to OpenAI image generation API, e.g.
`n`, `quality`, `response_format`, `size`, etc. Please refer to
https://platform.openai.com/docs/api-reference/images/create
for more detailed arguments.
Returns:
`ModelResponse`:
A list of image urls in image_urls field and the
raw response in raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved for
`_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and check
the response generated by the model, which takes the response
as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: forward to generate response
try:
response = self.client.images.generate(
model=self.model_name,
prompt=prompt,
**kwargs,
)
except Exception as e:
logger.error(
f"Failed to generate images for prompt '{prompt}': {e}",
)
raise e
# step3: record the model api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"prompt": prompt,
**kwargs,
},
response=response.model_dump(),
)
# step4: update monitor accordingly
self.update_monitor(call_counter=1)
# step5: return response
raw_response = response.model_dump()
if "data" not in raw_response:
if "error" in raw_response:
error_msg = raw_response["error"]["message"]
else:
error_msg = raw_response
logger.error(f"Error in OpenAI API call:\n{error_msg}")
raise ValueError(f"Error in OpenAI API call:\n{error_msg}")
images = raw_response["data"]
# Get image urls as a list
urls = [_["url"] for _ in images]
if save_local:
# Return local url if save_local is True
urls = [file_manager.save_image(_) for _ in urls]
return ModelResponse(image_urls=urls, raw=raw_response)
class OpenAIEmbeddingWrapper(OpenAIWrapperBase):
"""The model wrapper for OpenAI embedding API."""
model_type: str = "openai_embedding"
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def __call__(
self,
texts: Union[list[str], str],
**kwargs: Any,
) -> ModelResponse:
"""Embed the messages with OpenAI embedding API.
Args:
texts (`list[str]` or `str`):
The messages used to embed.
**kwargs (`Any`):
The keyword arguments to OpenAI embedding API,
e.g. `encoding_format`, `user`. Please refer to
https://platform.openai.com/docs/api-reference/embeddings
for more detailed arguments.
Returns:
`ModelResponse`:
A list of embeddings in embedding field and the
raw response in raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved for
`_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and check
the response generated by the model, which takes the response
as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: forward to generate response
response = self.client.embeddings.create(
input=texts,
model=self.model_name,
**kwargs,
)
# step3: record the model api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"input": texts,
**kwargs,
},
response=response.model_dump(),
)
# step4: update monitor accordingly
self.update_monitor(call_counter=1, **response.usage.model_dump())
# step5: return response
response_json = response.model_dump()
return ModelResponse(
embedding=[_["embedding"] for _ in response_json["data"]],
raw=response_json,
)

View File

@@ -0,0 +1,302 @@
# -*- coding: utf-8 -*-
"""Model wrapper for post-based inference apis."""
import json
import time
from abc import ABC
from typing import Any, Union, Sequence, List
import requests
from loguru import logger
from .model import ModelWrapperBase, ModelResponse
from ..constants import _DEFAULT_MAX_RETRIES
from ..constants import _DEFAULT_MESSAGES_KEY
from ..constants import _DEFAULT_RETRY_INTERVAL
from ..message import Msg
from ..utils.tools import _convert_to_str
class PostAPIModelWrapperBase(ModelWrapperBase, ABC):
"""The base model wrapper for the model deployed on the POST API."""
model_type: str = "post_api"
def __init__(
self,
config_name: str,
api_url: str,
headers: dict = None,
max_length: int = 2048,
timeout: int = 30,
json_args: dict = None,
post_args: dict = None,
max_retries: int = _DEFAULT_MAX_RETRIES,
messages_key: str = _DEFAULT_MESSAGES_KEY,
retry_interval: int = _DEFAULT_RETRY_INTERVAL,
**kwargs: Any,
) -> None:
"""Initialize the model wrapper.
Args:
config_name (`str`):
The id of the model.
api_url (`str`):
The url of the post request api.
headers (`dict`, defaults to `None`):
The headers of the api. Defaults to None.
max_length (`int`, defaults to `2048`):
The maximum length of the model.
timeout (`int`, defaults to `30`):
The timeout of the api. Defaults to 30.
json_args (`dict`, defaults to `None`):
The json arguments of the api. Defaults to None.
post_args (`dict`, defaults to `None`):
The post arguments of the api. Defaults to None.
max_retries (`int`, defaults to `3`):
The maximum number of retries when the `parse_func` raise an
exception.
messages_key (`str`, defaults to `inputs`):
The key of the input messages in the json argument.
retry_interval (`int`, defaults to `1`):
The interval between retries when a request fails.
Note:
When an object of `PostApiModelWrapper` is called, the arguments
will of post requests will be used as follows:
.. code-block:: python
request.post(
url=api_url,
headers=headers,
json={
messages_key: messages,
**json_args
},
**post_args
)
"""
super().__init__(config_name=config_name)
self.api_url = api_url
self.headers = headers
self.max_length = max_length
self.timeout = timeout
self.json_args = json_args or {}
self.post_args = post_args or {}
self.max_retries = max_retries
self.messages_key = messages_key
self.retry_interval = retry_interval
def _parse_response(self, response: dict) -> ModelResponse:
"""Parse the response json data into ModelResponse"""
return ModelResponse(raw=response)
def __call__(self, input_: str, **kwargs: Any) -> ModelResponse:
"""Calling the model with requests.post.
Args:
input_ (`str`):
The input string to the model.
Returns:
`dict`: A dictionary that contains the response of the model and
related
information (e.g. cost, time, the number of tokens, etc.).
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved for
`_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and check
the response generated by the model, which takes the response
as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
post_args = {**self.post_args, **kwargs}
request_kwargs = {
"url": self.api_url,
"json": {self.messages_key: input_, **self.json_args},
"headers": self.headers or {},
**post_args,
}
# step2: prepare post requests
for i in range(1, self.max_retries + 1):
response = requests.post(**request_kwargs)
if response.status_code == requests.codes.ok:
break
if i < self.max_retries:
logger.warning(
f"Failed to call the model with "
f"requests.codes == {response.status_code}, retry "
f"{i + 1}/{self.max_retries} times",
)
time.sleep(i * self.retry_interval)
# step3: record model invocation
# record the model api invocation, which will be skipped if
# `FileManager.save_api_invocation` is `False`
self._save_model_invocation(
arguments=request_kwargs,
response=response.json(),
)
# step4: parse the response
if response.status_code == requests.codes.ok:
return self._parse_response(response.json())
else:
logger.error(json.dumps(request_kwargs, indent=4))
raise RuntimeError(
f"Failed to call the model with {response.json()}",
)
class PostAPIChatWrapper(PostAPIModelWrapperBase):
"""A post api model wrapper compatible with openai chat, e.g., vLLM,
FastChat."""
model_type: str = "post_api_chat"
def _parse_response(self, response: dict) -> ModelResponse:
return ModelResponse(
text=response["data"]["response"]["choices"][0]["message"][
"content"
],
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict]]:
"""Format the input messages into a list of dict, which is
compatible to OpenAI Chat API.
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`Union[List[dict]]`:
The formatted messages.
"""
messages = []
for arg in args:
if arg is None:
continue
if isinstance(arg, Msg):
messages.append(
{
"role": arg.role,
"name": arg.name,
"content": _convert_to_str(arg.content),
},
)
elif isinstance(arg, list):
messages.extend(self.format(*arg))
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(arg)}.",
)
return messages
class PostAPIDALLEWrapper(PostAPIModelWrapperBase):
"""A post api model wrapper compatible with openai dall_e"""
model_type: str = "post_api_dall_e"
deprecated_model_type: str = "post_api_dalle"
def _parse_response(self, response: dict) -> ModelResponse:
if "data" not in response["data"]["response"]:
if "error" in response["data"]["response"]:
error_msg = response["data"]["response"]["error"]["message"]
else:
error_msg = response["data"]["response"]
logger.error(f"Error in API call:\n{error_msg}")
raise ValueError(f"Error in API call:\n{error_msg}")
urls = [img["url"] for img in response["data"]["response"]["data"]]
return ModelResponse(image_urls=urls)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
raise RuntimeError(
f"Model Wrapper [{type(self).__name__}] doesn't "
f"need to format the input. Please try to use the "
f"model wrapper directly.",
)
class PostAPIEmbeddingWrapper(PostAPIModelWrapperBase):
"""
A post api model wrapper for embedding model
"""
model_type: str = "post_api_embedding"
def _parse_response(self, response: dict) -> ModelResponse:
"""
Parse the response json data into ModelResponse with embedding.
Args:
response (`dict`):
The response obtained from the API. This parsing assume the
structure of the response is as following:
{
"code": 200,
"data": {
...
"response": {
"data": [
{
"embedding": [
0.001,
...
],
...
}
],
"model": "xxxx",
...
},
},
}
"""
if "data" not in response["data"]["response"]:
if "error" in response["data"]["response"]:
error_msg = response["data"]["response"]["error"]["message"]
else:
error_msg = response["data"]["response"]
logger.error(f"Error in embedding API call:\n{error_msg}")
raise ValueError(f"Error in embedding API call:\n{error_msg}")
embeddings = [
data["embedding"] for data in response["data"]["response"]["data"]
]
return ModelResponse(
embedding=embeddings,
raw=response,
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
raise RuntimeError(
f"Model Wrapper [{type(self).__name__}] doesn't "
f"need to format the input. Please try to use the "
f"model wrapper directly.",
)

View File

@@ -0,0 +1,85 @@
# -*- coding: utf-8 -*-
"""Parser for model response."""
import json
from typing import Optional, Sequence, Any
from loguru import logger
from agentscope.utils.tools import _is_json_serializable
class ModelResponse:
"""Encapsulation of data returned by the model.
The main purpose of this class is to align the return formats of different
models and act as a bridge between models and agents.
"""
text: Optional[str] = None
embedding: Optional[Sequence] = None
raw: Optional[Any] = None
image_urls: Optional[Sequence[str]] = None
parsed: Optional[Any] = None
def __init__(
self,
text: str = None,
embedding: Sequence = None,
image_urls: Sequence[str] = None,
raw: Any = None,
parsed: Any = None,
) -> None:
"""Initialize the model response.
Args:
text (`str`, optional):
The text field.
embedding (`Sequence`, optional):
The embedding returned by the model.
image_urls (`Sequence[str]`, optional):
The image URLs returned by the model.
raw (`Any`, optional):
The raw data returned by the model.
parsed (`Any`, optional):
The parsed data returned by the model.
"""
self.text = text
self.embedding = embedding
self.image_urls = image_urls
self.raw = raw
self.parsed = parsed
def __getattribute__(self, item: str) -> Any:
"""Warning for the deprecated json attribute."""
if item == "json":
logger.warning(
"The json attribute in ModelResponse class is deprecated. Use"
" parsed attribute instead.",
)
return super().__getattribute__(item)
def __setattr__(self, key: str, value: Any) -> Optional[Any]:
"""Warning for the deprecated json attribute."""
if key == "json":
logger.warning(
"The json attribute in ModelResponse class is deprecated. Use"
" parsed attribute instead.",
)
return super().__setattr__(key, value)
def __str__(self) -> str:
if _is_json_serializable(self.raw):
raw = self.raw
else:
raw = str(self.raw)
serialized_fields = {
"text": self.text,
"embedding": self.embedding,
"image_urls": self.image_urls,
"parsed": self.parsed,
"raw": raw,
}
return json.dumps(serialized_fields, indent=4, ensure_ascii=False)

View File

@@ -0,0 +1,348 @@
# -*- coding: utf-8 -*-
"""Model wrapper for ZhipuAI models"""
from abc import ABC
from typing import Union, Any, List, Sequence
from loguru import logger
from .model import ModelWrapperBase, ModelResponse
from ..message import Msg
from ..utils.tools import _convert_to_str
try:
import zhipuai
except ImportError:
zhipuai = None
class ZhipuAIWrapperBase(ModelWrapperBase, ABC):
"""The model wrapper for ZhipuAI API."""
def __init__(
self,
config_name: str,
model_name: str = None,
api_key: str = None,
client_args: dict = None,
generate_args: dict = None,
**kwargs: Any,
) -> None:
"""Initialize the zhipuai client.
To init the ZhipuAi client, the api_key is required.
Other client args include base_url and timeout.
The base_url is set to https://open.bigmodel.cn/api/paas/v4
if not specified. The timeout arg is set for http request timeout.
Args:
config_name (`str`):
The name of the model config.
model_name (`str`, default `None`):
The name of the model to use in ZhipuAI API.
api_key (`str`, default `None`):
The API key for ZhipuAI API. If not specified, it will
be read from the environment variable.
client_args (`dict`, default `None`):
The extra keyword arguments to initialize the ZhipuAI client.
generate_args (`dict`, default `None`):
The extra keyword arguments used in zhipuai api generation,
e.g. `temperature`, `seed`.
"""
if model_name is None:
model_name = config_name
logger.warning("model_name is not set, use config_name instead.")
super().__init__(config_name=config_name)
if zhipuai is None:
raise ImportError(
"Cannot find zhipuai package in current python environment.",
)
self.model_name = model_name
self.generate_args = generate_args or {}
self.client = zhipuai.ZhipuAI(
api_key=api_key,
**(client_args or {}),
)
self._register_default_metrics()
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> Union[List[dict], str]:
raise RuntimeError(
f"Model Wrapper [{type(self).__name__}] doesn't "
f"need to format the input. Please try to use the "
f"model wrapper directly.",
)
class ZhipuAIChatWrapper(ZhipuAIWrapperBase):
"""The model wrapper for ZhipuAI's chat API."""
model_type: str = "zhipuai_chat"
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)
def __call__(
self,
messages: list,
**kwargs: Any,
) -> ModelResponse:
"""Processes a list of messages to construct a payload for the ZhipuAI
API call. It then makes a request to the ZhipuAI API and returns the
response. This method also updates monitoring metrics based on the
API response.
Args:
messages (`list`):
A list of messages to process.
**kwargs (`Any`):
The keyword arguments to ZhipuAI chat completions API,
e.g. `temperature`, `max_tokens`, `top_p`, etc. Please refer to
https://open.bigmodel.cn/dev/api
for more detailed arguments.
Returns:
`ModelResponse`:
The response text in text field, and the raw response in
raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved for
`_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and check
the response generated by the model, which takes the response
as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: checking messages
if not isinstance(messages, list):
raise ValueError(
"ZhipuAI `messages` field expected type `list`, "
f"got `{type(messages)}` instead.",
)
if not all("role" in msg and "content" in msg for msg in messages):
raise ValueError(
"Each message in the 'messages' list must contain a 'role' "
"and 'content' key for ZhipuAI API.",
)
# step3: forward to generate response
response = self.client.chat.completions.create(
model=self.model_name,
messages=messages,
**kwargs,
)
# step4: record the api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"messages": messages,
**kwargs,
},
response=response.model_dump(),
)
# step5: update monitor accordingly
self.update_monitor(call_counter=1, **response.usage.model_dump())
# step6: return response
return ModelResponse(
text=response.choices[0].message.content,
raw=response.model_dump(),
)
def format(
self,
*args: Union[Msg, Sequence[Msg]],
) -> List[dict]:
"""Format the input string and dictionary into the format that
ZhipuAI Chat API required.
In this format function, the input messages are formatted into a
single system messages with format "{name}: {content}" for each
message. Note this strategy maybe not suitable for all scenarios,
and developers are encouraged to implement their own prompt
engineering strategies.
Args:
args (`Union[Msg, Sequence[Msg]]`):
The input arguments to be formatted, where each argument
should be a `Msg` object, or a list of `Msg` objects.
In distribution, placeholder is also allowed.
Returns:
`List[dict]`:
The formatted messages in the format that ZhipuAI Chat API
required.
"""
# Parse all information into a list of messages
input_msgs = []
for _ in args:
if _ is None:
continue
if isinstance(_, Msg):
input_msgs.append(_)
elif isinstance(_, list) and all(isinstance(__, Msg) for __ in _):
input_msgs.extend(_)
else:
raise TypeError(
f"The input should be a Msg object or a list "
f"of Msg objects, got {type(_)}.",
)
messages = []
# record dialog history as a list of strings
dialogue = []
for i, unit in enumerate(input_msgs):
if i == 0 and unit.role == "system":
# system prompt
messages.append(
{
"role": unit.role,
"content": _convert_to_str(unit.content),
},
)
else:
# Merge all messages into a dialogue history prompt
dialogue.append(
f"{unit.name}: {_convert_to_str(unit.content)}",
)
dialogue_history = "\n".join(dialogue)
user_content_template = "## Dialogue History\n{dialogue_history}"
messages.append(
{
"role": "user",
"content": user_content_template.format(
dialogue_history=dialogue_history,
),
},
)
return messages
class ZhipuAIEmbeddingWrapper(ZhipuAIWrapperBase):
"""The model wrapper for ZhipuAI embedding API."""
model_type: str = "zhipuai_embedding"
def __call__(
self,
texts: str,
**kwargs: Any,
) -> ModelResponse:
"""Embed the messages with ZhipuAI embedding API.
Args:
texts (`str`):
The messages used to embed.
**kwargs (`Any`):
The keyword arguments to ZhipuAI embedding API,
e.g. `encoding_format`, `user`. Please refer to
https://open.bigmodel.cn/dev/api#text_embedding
for more detailed arguments.
Returns:
`ModelResponse`:
A list of embeddings in embedding field and the
raw response in raw field.
Note:
`parse_func`, `fault_handler` and `max_retries` are reserved for
`_response_parse_decorator` to parse and check the response
generated by model wrapper. Their usages are listed as follows:
- `parse_func` is a callable function used to parse and check
the response generated by the model, which takes the response
as input.
- `max_retries` is the maximum number of retries when the
`parse_func` raise an exception.
- `fault_handler` is a callable function which is called
when the response generated by the model is invalid after
`max_retries` retries.
"""
# step1: prepare keyword arguments
kwargs = {**self.generate_args, **kwargs}
# step2: forward to generate response
response = self.client.embeddings.create(
input=texts,
model=self.model_name,
)
# step3: record the model api invocation if needed
self._save_model_invocation(
arguments={
"model": self.model_name,
"input": texts,
**kwargs,
},
response=response.model_dump(),
)
# step4: update monitor accordingly
self.update_monitor(call_counter=1, **response.usage.model_dump())
# step5: return response
response_json = response.model_dump()
return ModelResponse(
embedding=[_["embedding"] for _ in response_json["data"]],
raw=response_json,
)
def _register_default_metrics(self) -> None:
# Set monitor accordingly
# TODO: set quota to the following metrics
self.monitor.register(
self._metric("call_counter"),
metric_unit="times",
)
self.monitor.register(
self._metric("prompt_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("completion_tokens"),
metric_unit="token",
)
self.monitor.register(
self._metric("total_tokens"),
metric_unit="token",
)