mirror of
https://github.com/langgenius/dify-plugin-sdks.git
synced 2026-07-22 10:25:23 -04:00
954 lines
39 KiB
Python
954 lines
39 KiB
Python
import codecs
|
|
import json
|
|
import logging
|
|
import uuid
|
|
from collections.abc import Generator
|
|
from decimal import Decimal
|
|
from typing import Any, Union, cast
|
|
from urllib.parse import urljoin
|
|
|
|
import requests
|
|
from pydantic import TypeAdapter, ValidationError
|
|
|
|
from dify_plugin.entities import I18nObject
|
|
from dify_plugin.entities.model import (
|
|
AIModelEntity,
|
|
DefaultParameterName,
|
|
FetchFrom,
|
|
ModelFeature,
|
|
ModelPropertyKey,
|
|
ModelType,
|
|
ParameterRule,
|
|
ParameterType,
|
|
PriceConfig,
|
|
)
|
|
from dify_plugin.entities.model.llm import (
|
|
LLMMode,
|
|
LLMResult,
|
|
LLMResultChunk,
|
|
LLMResultChunkDelta,
|
|
)
|
|
from dify_plugin.entities.model.message import (
|
|
AssistantPromptMessage,
|
|
ImagePromptMessageContent,
|
|
PromptMessage,
|
|
PromptMessageContent,
|
|
PromptMessageContentType,
|
|
PromptMessageFunction,
|
|
PromptMessageTool,
|
|
SystemPromptMessage,
|
|
ToolPromptMessage,
|
|
UserPromptMessage,
|
|
)
|
|
from dify_plugin.errors.model import CredentialsValidateFailedError, InvokeError
|
|
from dify_plugin.interfaces.model.large_language_model import LargeLanguageModel
|
|
from dify_plugin.interfaces.model.openai_compatible.common import _CommonOaiApiCompat
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
def _gen_tool_call_id() -> str:
|
|
return f"chatcmpl-tool-{uuid.uuid4().hex!s}"
|
|
|
|
|
|
def _increase_tool_call(
|
|
new_tool_calls: list[AssistantPromptMessage.ToolCall], existing_tools_calls: list[AssistantPromptMessage.ToolCall]
|
|
):
|
|
"""
|
|
Merge incremental tool call updates into existing tool calls.
|
|
|
|
:param new_tool_calls: List of new tool call deltas to be merged.
|
|
:param existing_tools_calls: List of existing tool calls to be modified IN-PLACE.
|
|
"""
|
|
|
|
def get_tool_call(tool_call_id: str):
|
|
"""
|
|
Get or create a tool call by ID
|
|
|
|
:param tool_call_id: tool call ID
|
|
:return: existing or new tool call
|
|
"""
|
|
if not tool_call_id:
|
|
return existing_tools_calls[-1]
|
|
|
|
_tool_call = next((_tool_call for _tool_call in existing_tools_calls if _tool_call.id == tool_call_id), None)
|
|
if _tool_call is None:
|
|
_tool_call = AssistantPromptMessage.ToolCall(
|
|
id=tool_call_id,
|
|
type="function",
|
|
function=AssistantPromptMessage.ToolCall.ToolCallFunction(name="", arguments=""),
|
|
)
|
|
existing_tools_calls.append(_tool_call)
|
|
|
|
return _tool_call
|
|
|
|
for new_tool_call in new_tool_calls:
|
|
# generate ID for tool calls with function name but no ID to track them
|
|
if new_tool_call.function.name and not new_tool_call.id:
|
|
new_tool_call.id = _gen_tool_call_id()
|
|
# get tool call
|
|
tool_call = get_tool_call(new_tool_call.id)
|
|
# update tool call
|
|
if new_tool_call.id:
|
|
tool_call.id = new_tool_call.id
|
|
if new_tool_call.type:
|
|
tool_call.type = new_tool_call.type
|
|
if new_tool_call.function.name:
|
|
tool_call.function.name = new_tool_call.function.name
|
|
if new_tool_call.function.arguments:
|
|
tool_call.function.arguments += new_tool_call.function.arguments
|
|
|
|
|
|
class OAICompatLargeLanguageModel(_CommonOaiApiCompat, LargeLanguageModel):
|
|
"""
|
|
Model class for OpenAI large language model.
|
|
"""
|
|
|
|
def _invoke(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
prompt_messages: list[PromptMessage],
|
|
model_parameters: dict,
|
|
tools: list[PromptMessageTool] | None = None,
|
|
stop: list[str] | None = None,
|
|
stream: bool = True,
|
|
user: str | None = None,
|
|
) -> Union[LLMResult, Generator]:
|
|
"""
|
|
Invoke large language model
|
|
|
|
:param model: model name
|
|
:param credentials: model credentials
|
|
:param prompt_messages: prompt messages
|
|
:param model_parameters: model parameters
|
|
:param tools: tools for tool calling
|
|
:param stop: stop words
|
|
:param stream: is stream response
|
|
:param user: unique user id
|
|
:return: full response or stream response chunk generator result
|
|
"""
|
|
|
|
# text completion model
|
|
return self._generate(
|
|
model=model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
model_parameters=model_parameters,
|
|
tools=tools,
|
|
stop=stop,
|
|
stream=stream,
|
|
user=user,
|
|
)
|
|
|
|
def get_num_tokens(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
prompt_messages: list[PromptMessage],
|
|
tools: list[PromptMessageTool] | None = None,
|
|
) -> int:
|
|
"""
|
|
Get number of tokens for given prompt messages
|
|
|
|
:param model:
|
|
:param credentials:
|
|
:param prompt_messages:
|
|
:param tools: tools for tool calling
|
|
:return:
|
|
"""
|
|
return self._num_tokens_from_messages(prompt_messages, tools, credentials)
|
|
|
|
def validate_credentials(self, model: str, credentials: dict) -> None:
|
|
"""
|
|
Validate model credentials using requests to ensure compatibility with all providers following
|
|
OpenAI's API standard.
|
|
|
|
:param model: model name
|
|
:param credentials: model credentials
|
|
:return:
|
|
"""
|
|
try:
|
|
headers = {"Content-Type": "application/json"}
|
|
|
|
api_key = credentials.get("api_key")
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
endpoint_url = credentials["endpoint_url"]
|
|
if not endpoint_url.endswith("/"):
|
|
endpoint_url += "/"
|
|
|
|
# prepare the payload for a simple ping to the model
|
|
data = {"model": credentials.get("endpoint_model_name", model), "max_tokens": 5}
|
|
|
|
completion_type = LLMMode.value_of(credentials["mode"])
|
|
|
|
if completion_type is LLMMode.CHAT:
|
|
data["messages"] = [
|
|
{"role": "user", "content": "ping"},
|
|
]
|
|
endpoint_url = urljoin(endpoint_url, "chat/completions")
|
|
elif completion_type is LLMMode.COMPLETION:
|
|
data["prompt"] = "ping"
|
|
endpoint_url = urljoin(endpoint_url, "completions")
|
|
else:
|
|
raise ValueError("Unsupported completion type for model configuration.")
|
|
|
|
# ADD stream validate_credentials
|
|
stream_mode_auth = credentials.get("stream_mode_auth", "not_use")
|
|
if stream_mode_auth == "use":
|
|
data["stream"] = True
|
|
data["max_tokens"] = 10
|
|
response = requests.post(endpoint_url, headers=headers, json=data, timeout=(10, 300), stream=True)
|
|
if response.status_code != 200:
|
|
raise CredentialsValidateFailedError(
|
|
f"Credentials validation failed with status code {response.status_code} "
|
|
f"and response body {response.text}"
|
|
)
|
|
return
|
|
|
|
# send a post request to validate the credentials
|
|
response = requests.post(endpoint_url, headers=headers, json=data, timeout=(10, 300))
|
|
|
|
if response.status_code != 200:
|
|
raise CredentialsValidateFailedError(
|
|
f"Credentials validation failed with status code {response.status_code} "
|
|
f"and response body {response.text}"
|
|
)
|
|
|
|
try:
|
|
json_result = response.json()
|
|
except json.JSONDecodeError:
|
|
raise CredentialsValidateFailedError(
|
|
f"Credentials validation failed: JSON decode error, response body {response.text}"
|
|
) from None
|
|
|
|
if completion_type is LLMMode.CHAT and json_result.get("object", "") == "":
|
|
json_result["object"] = "chat.completion"
|
|
elif completion_type is LLMMode.COMPLETION and json_result.get("object", "") == "":
|
|
json_result["object"] = "text_completion"
|
|
|
|
if completion_type is LLMMode.CHAT and (
|
|
"object" not in json_result or json_result["object"] != "chat.completion"
|
|
):
|
|
raise CredentialsValidateFailedError(
|
|
f"Credentials validation failed: invalid response object, "
|
|
f"must be 'chat.completion', response body {response.text}"
|
|
)
|
|
elif completion_type is LLMMode.COMPLETION and (
|
|
"object" not in json_result or json_result["object"] != "text_completion"
|
|
):
|
|
raise CredentialsValidateFailedError(
|
|
f"Credentials validation failed: invalid response object, "
|
|
f"must be 'text_completion', response body {response.text}"
|
|
)
|
|
except CredentialsValidateFailedError:
|
|
raise
|
|
except Exception as ex:
|
|
raise CredentialsValidateFailedError(
|
|
f"An error occurred during credentials validation: {ex!s}, response body {response.text}"
|
|
) from ex
|
|
|
|
def get_customizable_model_schema(self, model: str, credentials: dict) -> AIModelEntity:
|
|
"""
|
|
generate custom model entities from credentials
|
|
"""
|
|
features = []
|
|
|
|
function_calling_type = credentials.get("function_calling_type", "no_call")
|
|
if function_calling_type == "function_call":
|
|
features.append(ModelFeature.TOOL_CALL)
|
|
elif function_calling_type == "tool_call":
|
|
features.append(ModelFeature.MULTI_TOOL_CALL)
|
|
|
|
stream_function_calling = credentials.get("stream_function_calling", "supported")
|
|
if stream_function_calling == "supported":
|
|
features.append(ModelFeature.STREAM_TOOL_CALL)
|
|
|
|
vision_support = credentials.get("vision_support", "not_support")
|
|
if vision_support == "support":
|
|
features.append(ModelFeature.VISION)
|
|
|
|
entity = AIModelEntity(
|
|
model=model,
|
|
label=I18nObject(en_US=model),
|
|
model_type=ModelType.LLM,
|
|
fetch_from=FetchFrom.CUSTOMIZABLE_MODEL,
|
|
features=features,
|
|
model_properties={
|
|
ModelPropertyKey.CONTEXT_SIZE: int(credentials.get("context_size", "4096")),
|
|
ModelPropertyKey.MODE: credentials.get("mode"),
|
|
},
|
|
parameter_rules=[
|
|
ParameterRule(
|
|
name=DefaultParameterName.TEMPERATURE.value,
|
|
label=I18nObject(en_US="Temperature", zh_Hans="温度"),
|
|
help=I18nObject(
|
|
en_US="Kernel sampling threshold. Used to determine the randomness of the results."
|
|
"The higher the value, the stronger the randomness."
|
|
"The higher the possibility of getting different answers to the same question.",
|
|
zh_Hans="核采样阈值。用于决定结果随机性,取值越高随机性越强即相同的问题得到的不同答案的可能性越高。",
|
|
),
|
|
type=ParameterType.FLOAT,
|
|
default=float(credentials.get("temperature", 0.7)),
|
|
min=0,
|
|
max=2,
|
|
precision=2,
|
|
),
|
|
ParameterRule(
|
|
name=DefaultParameterName.TOP_P.value,
|
|
label=I18nObject(en_US="Top P", zh_Hans="Top P"),
|
|
help=I18nObject(
|
|
en_US="The probability threshold of the nucleus sampling method during the generation process."
|
|
"The larger the value is, the higher the randomness of generation will be."
|
|
"The smaller the value is, the higher the certainty of generation will be.",
|
|
zh_Hans="生成过程中核采样方法概率阈值。取值越大,生成的随机性越高;取值越小,生成的确定性越高。",
|
|
),
|
|
type=ParameterType.FLOAT,
|
|
default=float(credentials.get("top_p", 1)),
|
|
min=0,
|
|
max=1,
|
|
precision=2,
|
|
),
|
|
ParameterRule(
|
|
name=DefaultParameterName.FREQUENCY_PENALTY.value,
|
|
label=I18nObject(en_US="Frequency Penalty", zh_Hans="频率惩罚"),
|
|
help=I18nObject(
|
|
en_US="For controlling the repetition rate of words used by the model."
|
|
"Increasing this can reduce the repetition of the same words in the model's output.",
|
|
zh_Hans="用于控制模型已使用字词的重复率。 提高此项可以降低模型在输出中重复相同字词的重复度。",
|
|
),
|
|
type=ParameterType.FLOAT,
|
|
default=float(credentials.get("frequency_penalty", 0)),
|
|
min=-2,
|
|
max=2,
|
|
),
|
|
ParameterRule(
|
|
name=DefaultParameterName.PRESENCE_PENALTY.value,
|
|
label=I18nObject(en_US="Presence Penalty", zh_Hans="存在惩罚"),
|
|
help=I18nObject(
|
|
en_US="Used to control the repetition rate when generating models."
|
|
"Increasing this can reduce the repetition rate of model generation.",
|
|
zh_Hans="用于控制模型生成时的重复度。提高此项可以降低模型生成的重复度。",
|
|
),
|
|
type=ParameterType.FLOAT,
|
|
default=float(credentials.get("presence_penalty", 0)),
|
|
min=-2,
|
|
max=2,
|
|
),
|
|
ParameterRule(
|
|
name=DefaultParameterName.MAX_TOKENS.value,
|
|
label=I18nObject(en_US="Max Tokens", zh_Hans="最大标记"),
|
|
help=I18nObject(
|
|
en_US="Maximum length of tokens for the model response.",
|
|
zh_Hans="模型回答的tokens的最大长度。",
|
|
),
|
|
type=ParameterType.INT,
|
|
default=512,
|
|
min=1,
|
|
max=int(credentials.get("max_tokens_to_sample", 4096)),
|
|
),
|
|
],
|
|
pricing=PriceConfig(
|
|
input=Decimal(credentials.get("input_price", 0)),
|
|
output=Decimal(credentials.get("output_price", 0)),
|
|
unit=Decimal(credentials.get("unit", 0)),
|
|
currency=credentials.get("currency", "USD"),
|
|
),
|
|
)
|
|
|
|
if credentials["mode"] == "chat":
|
|
entity.model_properties[ModelPropertyKey.MODE] = LLMMode.CHAT.value
|
|
elif credentials["mode"] == "completion":
|
|
entity.model_properties[ModelPropertyKey.MODE] = LLMMode.COMPLETION.value
|
|
else:
|
|
raise ValueError(f"Unknown completion type {credentials['completion_type']}")
|
|
|
|
return entity
|
|
|
|
# validate_credentials method has been rewritten to use the requests library for compatibility with all providers
|
|
# following OpenAI's API standard.
|
|
def _generate(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
prompt_messages: list[PromptMessage],
|
|
model_parameters: dict,
|
|
tools: list[PromptMessageTool] | None = None,
|
|
stop: list[str] | None = None,
|
|
stream: bool = True,
|
|
user: str | None = None,
|
|
) -> Union[LLMResult, Generator]:
|
|
"""
|
|
Invoke llm completion model
|
|
|
|
:param model: model name
|
|
:param credentials: credentials
|
|
:param prompt_messages: prompt messages
|
|
:param model_parameters: model parameters
|
|
:param stop: stop words
|
|
:param stream: is stream response
|
|
:param user: unique user id
|
|
:return: full response or stream response chunk generator result
|
|
"""
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"Accept-Charset": "utf-8",
|
|
}
|
|
extra_headers = credentials.get("extra_headers")
|
|
if extra_headers is not None:
|
|
headers = {
|
|
**headers,
|
|
**extra_headers,
|
|
}
|
|
|
|
api_key = credentials.get("api_key")
|
|
if api_key:
|
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
|
|
endpoint_url = credentials["endpoint_url"]
|
|
if not endpoint_url.endswith("/"):
|
|
endpoint_url += "/"
|
|
|
|
response_format = model_parameters.get("response_format")
|
|
if response_format:
|
|
if response_format == "json_schema":
|
|
json_schema = model_parameters.get("json_schema")
|
|
if not json_schema:
|
|
raise ValueError("Must define JSON Schema when the response format is json_schema")
|
|
try:
|
|
schema = TypeAdapter(dict[str, Any]).validate_json(json_schema)
|
|
except Exception as exc:
|
|
raise ValueError(f"not correct json_schema format: {json_schema}") from exc
|
|
model_parameters.pop("json_schema")
|
|
model_parameters["response_format"] = {"type": "json_schema", "json_schema": schema}
|
|
else:
|
|
model_parameters["response_format"] = {"type": response_format}
|
|
elif "json_schema" in model_parameters:
|
|
del model_parameters["json_schema"]
|
|
|
|
data = {"model": credentials.get("endpoint_model_name", model), "stream": stream, **model_parameters}
|
|
|
|
completion_type = LLMMode.value_of(credentials["mode"])
|
|
|
|
if completion_type is LLMMode.CHAT:
|
|
endpoint_url = urljoin(endpoint_url, "chat/completions")
|
|
data["messages"] = [self._convert_prompt_message_to_dict(m, credentials) for m in prompt_messages]
|
|
elif completion_type is LLMMode.COMPLETION:
|
|
endpoint_url = urljoin(endpoint_url, "completions")
|
|
data["prompt"] = prompt_messages[0].content
|
|
else:
|
|
raise ValueError("Unsupported completion type for model configuration.")
|
|
|
|
# annotate tools with names, descriptions, etc.
|
|
function_calling_type = credentials.get("function_calling_type", "no_call")
|
|
formatted_tools = []
|
|
if tools:
|
|
if function_calling_type == "function_call":
|
|
data["functions"] = [
|
|
{
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"parameters": tool.parameters,
|
|
}
|
|
for tool in tools
|
|
]
|
|
elif function_calling_type == "tool_call":
|
|
data["tool_choice"] = "auto"
|
|
|
|
for tool in tools:
|
|
formatted_tools.append(PromptMessageFunction(function=tool).model_dump())
|
|
|
|
data["tools"] = formatted_tools
|
|
|
|
if stop:
|
|
data["stop"] = stop
|
|
|
|
if user:
|
|
data["user"] = user
|
|
|
|
response = requests.post(endpoint_url, headers=headers, json=data, timeout=(10, 300), stream=stream)
|
|
|
|
if response.encoding is None or response.encoding == "ISO-8859-1":
|
|
response.encoding = "utf-8"
|
|
|
|
if response.status_code != 200:
|
|
raise InvokeError(f"API request failed with status code {response.status_code}: {response.text}")
|
|
|
|
if stream:
|
|
return self._handle_generate_stream_response(model, credentials, response, prompt_messages)
|
|
|
|
return self._handle_generate_response(model, credentials, response, prompt_messages)
|
|
|
|
def _create_final_llm_result_chunk(
|
|
self,
|
|
index: int,
|
|
message: AssistantPromptMessage,
|
|
finish_reason: str,
|
|
usage: dict,
|
|
model: str,
|
|
prompt_messages: list[PromptMessage],
|
|
credentials: dict,
|
|
full_content: str,
|
|
) -> LLMResultChunk:
|
|
# calculate num tokens
|
|
prompt_tokens = usage and usage.get("prompt_tokens")
|
|
if prompt_tokens is None:
|
|
prompt_tokens = self._num_tokens_from_string(text=prompt_messages[0].content)
|
|
completion_tokens = usage and usage.get("completion_tokens")
|
|
if completion_tokens is None:
|
|
completion_tokens = self._num_tokens_from_string(text=full_content)
|
|
|
|
# transform usage
|
|
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
|
|
|
|
return LLMResultChunk(
|
|
model=model,
|
|
delta=LLMResultChunkDelta(index=index, message=message, finish_reason=finish_reason, usage=usage),
|
|
)
|
|
|
|
def _handle_generate_stream_response(
|
|
self, model: str, credentials: dict, response: requests.Response, prompt_messages: list[PromptMessage]
|
|
) -> Generator:
|
|
"""
|
|
Handle llm stream response
|
|
|
|
:param model: model name
|
|
:param credentials: model credentials
|
|
:param response: streamed response
|
|
:param prompt_messages: prompt messages
|
|
:return: llm response chunk generator
|
|
"""
|
|
chunk_index = 0
|
|
full_assistant_content = ""
|
|
tools_calls: list[AssistantPromptMessage.ToolCall] = []
|
|
finish_reason = None
|
|
usage = None
|
|
is_reasoning_started = False
|
|
# delimiter for stream response, need unicode_escape
|
|
delimiter = credentials.get("stream_mode_delimiter", "\n\n")
|
|
delimiter = codecs.decode(delimiter, "unicode_escape")
|
|
for chunk in response.iter_lines(decode_unicode=True, delimiter=delimiter):
|
|
chunk = chunk.strip()
|
|
if chunk:
|
|
# ignore sse comments
|
|
if chunk.startswith(":"):
|
|
continue
|
|
decoded_chunk = chunk.strip().removeprefix("data:").lstrip()
|
|
if decoded_chunk == "[DONE]": # Some provider returns "data: [DONE]"
|
|
continue
|
|
|
|
try:
|
|
chunk_json: dict = TypeAdapter(dict[str, Any]).validate_json(decoded_chunk)
|
|
# stream ended
|
|
except ValidationError:
|
|
yield self._create_final_llm_result_chunk(
|
|
index=chunk_index + 1,
|
|
message=AssistantPromptMessage(content=""),
|
|
finish_reason="Non-JSON encountered.",
|
|
usage=usage,
|
|
model=model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
full_content=full_assistant_content,
|
|
)
|
|
break
|
|
# handle the error here. for issue #11629
|
|
if chunk_json.get("error") and chunk_json.get("choices") is None:
|
|
raise ValueError(chunk_json.get("error"))
|
|
|
|
if chunk_json: # noqa: SIM102
|
|
if u := chunk_json.get("usage"):
|
|
usage = u
|
|
if not chunk_json or len(chunk_json["choices"]) == 0:
|
|
continue
|
|
|
|
choice = chunk_json["choices"][0]
|
|
finish_reason = chunk_json["choices"][0].get("finish_reason")
|
|
chunk_index += 1
|
|
|
|
if "delta" in choice:
|
|
delta = choice["delta"]
|
|
delta_content, is_reasoning_started = self._wrap_thinking_by_reasoning_content(
|
|
delta, is_reasoning_started
|
|
)
|
|
|
|
assistant_message_tool_calls = None
|
|
|
|
if "tool_calls" in delta and credentials.get("function_calling_type", "no_call") == "tool_call":
|
|
assistant_message_tool_calls = delta.get("tool_calls", None)
|
|
elif (
|
|
"function_call" in delta
|
|
and credentials.get("function_calling_type", "no_call") == "function_call"
|
|
):
|
|
assistant_message_tool_calls = [
|
|
{"id": "tool_call_id", "type": "function", "function": delta.get("function_call", {})}
|
|
]
|
|
|
|
# extract tool calls from response
|
|
if assistant_message_tool_calls:
|
|
tool_calls = self._extract_response_tool_calls(assistant_message_tool_calls)
|
|
_increase_tool_call(tool_calls, tools_calls)
|
|
|
|
if delta_content is None or delta_content == "":
|
|
continue
|
|
|
|
# transform assistant message to prompt message
|
|
assistant_prompt_message = AssistantPromptMessage(
|
|
content=delta_content,
|
|
)
|
|
|
|
full_assistant_content += delta_content
|
|
elif "text" in choice:
|
|
choice_text = choice.get("text", "")
|
|
if choice_text == "":
|
|
continue
|
|
|
|
# transform assistant message to prompt message
|
|
assistant_prompt_message = AssistantPromptMessage(content=choice_text)
|
|
full_assistant_content += choice_text
|
|
else:
|
|
continue
|
|
|
|
yield LLMResultChunk(
|
|
model=model,
|
|
delta=LLMResultChunkDelta(
|
|
index=chunk_index,
|
|
message=assistant_prompt_message,
|
|
),
|
|
)
|
|
|
|
chunk_index += 1
|
|
|
|
if tools_calls:
|
|
yield LLMResultChunk(
|
|
model=model,
|
|
delta=LLMResultChunkDelta(
|
|
index=chunk_index,
|
|
message=AssistantPromptMessage(tool_calls=tools_calls, content=""),
|
|
),
|
|
)
|
|
|
|
yield self._create_final_llm_result_chunk(
|
|
index=chunk_index,
|
|
message=AssistantPromptMessage(content=""),
|
|
finish_reason=finish_reason,
|
|
usage=usage,
|
|
model=model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
full_content=full_assistant_content,
|
|
)
|
|
|
|
def _handle_generate_response(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
response: requests.Response,
|
|
prompt_messages: list[PromptMessage],
|
|
) -> LLMResult:
|
|
response_json: dict = response.json()
|
|
|
|
completion_type = LLMMode.value_of(credentials["mode"])
|
|
|
|
output = response_json["choices"][0]
|
|
message_id = response_json.get("id")
|
|
|
|
response_content = ""
|
|
tool_calls = None
|
|
function_calling_type = credentials.get("function_calling_type", "no_call")
|
|
if completion_type is LLMMode.CHAT:
|
|
response_content = output.get("message", {})["content"]
|
|
if function_calling_type == "tool_call":
|
|
tool_calls = output.get("message", {}).get("tool_calls")
|
|
elif function_calling_type == "function_call":
|
|
tool_calls = output.get("message", {}).get("function_call")
|
|
|
|
elif completion_type is LLMMode.COMPLETION:
|
|
response_content = output["text"]
|
|
|
|
assistant_message = AssistantPromptMessage(content=response_content, tool_calls=[])
|
|
|
|
if tool_calls:
|
|
if function_calling_type == "tool_call":
|
|
assistant_message.tool_calls = self._extract_response_tool_calls(tool_calls)
|
|
elif function_calling_type == "function_call":
|
|
assistant_message.tool_calls = [self._extract_response_function_call(tool_calls)]
|
|
|
|
usage = response_json.get("usage")
|
|
if usage:
|
|
# transform usage
|
|
prompt_tokens = usage["prompt_tokens"]
|
|
completion_tokens = usage["completion_tokens"]
|
|
else:
|
|
# calculate num tokens
|
|
assert prompt_messages[0].content is not None
|
|
prompt_tokens = self._num_tokens_from_string(model, prompt_messages[0].content)
|
|
assert assistant_message.content is not None
|
|
completion_tokens = self._num_tokens_from_string(model, assistant_message.content)
|
|
|
|
# transform usage
|
|
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
|
|
|
|
# transform response
|
|
result = LLMResult(
|
|
id=message_id,
|
|
model=response_json.get("model", model),
|
|
message=assistant_message,
|
|
usage=usage,
|
|
)
|
|
|
|
return result
|
|
|
|
def _convert_prompt_message_to_dict(self, message: PromptMessage, credentials: dict | None = None) -> dict:
|
|
"""
|
|
Convert PromptMessage to dict for OpenAI API format
|
|
"""
|
|
message_dict = {}
|
|
if isinstance(message, UserPromptMessage):
|
|
message = cast(UserPromptMessage, message)
|
|
if isinstance(message.content, str):
|
|
message_dict = {"role": "user", "content": message.content}
|
|
else:
|
|
sub_messages = []
|
|
for message_content in message.content or []:
|
|
if message_content.type == PromptMessageContentType.TEXT:
|
|
message_content = cast(PromptMessageContent, message_content)
|
|
sub_message_dict = {
|
|
"type": "text",
|
|
"text": message_content.data,
|
|
}
|
|
sub_messages.append(sub_message_dict)
|
|
elif message_content.type == PromptMessageContentType.IMAGE:
|
|
message_content = cast(ImagePromptMessageContent, message_content)
|
|
sub_message_dict = {
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": message_content.data,
|
|
"detail": message_content.detail.value,
|
|
},
|
|
}
|
|
sub_messages.append(sub_message_dict)
|
|
|
|
message_dict = {"role": "user", "content": sub_messages}
|
|
elif isinstance(message, AssistantPromptMessage):
|
|
message = cast(AssistantPromptMessage, message)
|
|
message_dict = {"role": "assistant", "content": message.content}
|
|
if message.tool_calls:
|
|
function_calling_type = credentials.get("function_calling_type", "no_call")
|
|
if function_calling_type == "tool_call":
|
|
message_dict["tool_calls"] = [tool_call.dict() for tool_call in message.tool_calls]
|
|
elif function_calling_type == "function_call":
|
|
function_call = message.tool_calls[0]
|
|
message_dict["function_call"] = {
|
|
"name": function_call.function.name,
|
|
"arguments": function_call.function.arguments,
|
|
}
|
|
elif isinstance(message, SystemPromptMessage):
|
|
message = cast(SystemPromptMessage, message)
|
|
message_dict = {"role": "system", "content": message.content}
|
|
elif isinstance(message, ToolPromptMessage):
|
|
message = cast(ToolPromptMessage, message)
|
|
function_calling_type = credentials.get("function_calling_type", "no_call")
|
|
if function_calling_type == "tool_call":
|
|
message_dict = {
|
|
"role": "tool",
|
|
"content": message.content,
|
|
"tool_call_id": message.tool_call_id,
|
|
}
|
|
elif function_calling_type == "function_call":
|
|
message_dict = {
|
|
"role": "function",
|
|
"content": message.content,
|
|
"name": message.tool_call_id,
|
|
}
|
|
else:
|
|
raise ValueError(f"Got unknown type {message}")
|
|
|
|
if message.name and message_dict.get("role", "") != "tool":
|
|
message_dict["name"] = message.name
|
|
|
|
return message_dict
|
|
|
|
def _num_tokens_from_string(
|
|
self,
|
|
text: Union[str, list[PromptMessageContent]],
|
|
tools: list[PromptMessageTool] | None = None,
|
|
) -> int:
|
|
"""
|
|
Approximate num tokens for model with gpt2 tokenizer.
|
|
|
|
:param text: prompt text
|
|
:param tools: tools for tool calling
|
|
:return: number of tokens
|
|
"""
|
|
if isinstance(text, str):
|
|
full_text = text
|
|
else:
|
|
full_text = ""
|
|
for message_content in text:
|
|
if message_content.type == PromptMessageContentType.TEXT:
|
|
message_content = cast(PromptMessageContent, message_content)
|
|
full_text += message_content.data
|
|
|
|
num_tokens = self._get_num_tokens_by_gpt2(full_text)
|
|
|
|
if tools:
|
|
num_tokens += self._num_tokens_for_tools(tools)
|
|
|
|
return num_tokens
|
|
|
|
def _num_tokens_from_messages(
|
|
self,
|
|
messages: list[PromptMessage],
|
|
tools: list[PromptMessageTool] | None = None,
|
|
credentials: dict | None = None,
|
|
) -> int:
|
|
"""
|
|
Approximate num tokens with GPT2 tokenizer.
|
|
"""
|
|
|
|
tokens_per_message = 3
|
|
tokens_per_name = 1
|
|
|
|
num_tokens = 0
|
|
messages_dict = [self._convert_prompt_message_to_dict(m, credentials) for m in messages]
|
|
for message in messages_dict:
|
|
num_tokens += tokens_per_message
|
|
for key, value in message.items():
|
|
# Cast str(value) in case the message value is not a string
|
|
# This occurs with function messages
|
|
# TODO: The current token calculation method for the image type is not implemented,
|
|
# which need to download the image and then get the resolution for calculation,
|
|
# and will increase the request delay
|
|
if isinstance(value, list):
|
|
text = ""
|
|
for item in value:
|
|
if isinstance(item, dict) and item["type"] == "text":
|
|
text += item["text"]
|
|
|
|
value = text
|
|
|
|
if key == "tool_calls":
|
|
for tool_call in value or []:
|
|
for t_key, t_value in tool_call.items():
|
|
num_tokens += self._get_num_tokens_by_gpt2(t_key)
|
|
if t_key == "function":
|
|
for f_key, f_value in t_value.items():
|
|
num_tokens += self._get_num_tokens_by_gpt2(f_key)
|
|
num_tokens += self._get_num_tokens_by_gpt2(f_value)
|
|
else:
|
|
num_tokens += self._get_num_tokens_by_gpt2(t_key)
|
|
num_tokens += self._get_num_tokens_by_gpt2(t_value)
|
|
else:
|
|
num_tokens += self._get_num_tokens_by_gpt2(str(value))
|
|
|
|
if key == "name":
|
|
num_tokens += tokens_per_name
|
|
|
|
# every reply is primed with <im_start>assistant
|
|
num_tokens += 3
|
|
|
|
if tools:
|
|
num_tokens += self._num_tokens_for_tools(tools)
|
|
|
|
return num_tokens
|
|
|
|
def _num_tokens_for_tools(self, tools: list[PromptMessageTool]) -> int:
|
|
"""
|
|
Calculate num tokens for tool calling with tiktoken package.
|
|
|
|
:param tools: tools for tool calling
|
|
:return: number of tokens
|
|
"""
|
|
num_tokens = 0
|
|
for tool in tools:
|
|
num_tokens += self._get_num_tokens_by_gpt2("type")
|
|
num_tokens += self._get_num_tokens_by_gpt2("function")
|
|
num_tokens += self._get_num_tokens_by_gpt2("function")
|
|
|
|
# calculate num tokens for function object
|
|
num_tokens += self._get_num_tokens_by_gpt2("name")
|
|
if hasattr(tool, "name"):
|
|
num_tokens += self._get_num_tokens_by_gpt2(tool.name)
|
|
num_tokens += self._get_num_tokens_by_gpt2("description")
|
|
if hasattr(tool, "description"):
|
|
num_tokens += self._get_num_tokens_by_gpt2(tool.description)
|
|
if hasattr(tool, "parameters"):
|
|
parameters = tool.parameters
|
|
num_tokens += self._get_num_tokens_by_gpt2("parameters")
|
|
if "title" in parameters:
|
|
num_tokens += self._get_num_tokens_by_gpt2("title")
|
|
num_tokens += self._get_num_tokens_by_gpt2(parameters.get("title"))
|
|
num_tokens += self._get_num_tokens_by_gpt2("type")
|
|
num_tokens += self._get_num_tokens_by_gpt2(parameters.get("type"))
|
|
if "properties" in parameters:
|
|
num_tokens += self._get_num_tokens_by_gpt2("properties")
|
|
for key, value in parameters.get("properties", {}).items():
|
|
num_tokens += self._get_num_tokens_by_gpt2(key)
|
|
for field_key, field_value in value.items():
|
|
num_tokens += self._get_num_tokens_by_gpt2(field_key)
|
|
if field_key == "enum":
|
|
for enum_field in field_value:
|
|
num_tokens += 3
|
|
num_tokens += self._get_num_tokens_by_gpt2(enum_field)
|
|
else:
|
|
num_tokens += self._get_num_tokens_by_gpt2(field_key)
|
|
num_tokens += self._get_num_tokens_by_gpt2(str(field_value))
|
|
if "required" in parameters:
|
|
num_tokens += self._get_num_tokens_by_gpt2("required")
|
|
for required_field in parameters["required"]:
|
|
num_tokens += 3
|
|
num_tokens += self._get_num_tokens_by_gpt2(required_field)
|
|
|
|
return num_tokens
|
|
|
|
def _extract_response_tool_calls(self, response_tool_calls: list[dict]) -> list[AssistantPromptMessage.ToolCall]:
|
|
"""
|
|
Extract tool calls from response
|
|
|
|
:param response_tool_calls: response tool calls
|
|
:return: list of tool calls
|
|
"""
|
|
tool_calls = []
|
|
if response_tool_calls:
|
|
for response_tool_call in response_tool_calls:
|
|
if not response_tool_call.get("function"):
|
|
continue
|
|
function = AssistantPromptMessage.ToolCall.ToolCallFunction(
|
|
name=response_tool_call.get("function", {}).get("name", ""),
|
|
arguments=response_tool_call.get("function", {}).get("arguments", ""),
|
|
)
|
|
|
|
tool_call = AssistantPromptMessage.ToolCall(
|
|
id=response_tool_call.get("id", ""),
|
|
type=response_tool_call.get("type", ""),
|
|
function=function,
|
|
)
|
|
tool_calls.append(tool_call)
|
|
|
|
return tool_calls
|
|
|
|
def _extract_response_function_call(self, response_function_call) -> AssistantPromptMessage.ToolCall | None:
|
|
"""
|
|
Extract function call from response
|
|
|
|
:param response_function_call: response function call
|
|
:return: tool call
|
|
"""
|
|
tool_call = None
|
|
if response_function_call:
|
|
function = AssistantPromptMessage.ToolCall.ToolCallFunction(
|
|
name=response_function_call.get("name", ""),
|
|
arguments=response_function_call.get("arguments", ""),
|
|
)
|
|
|
|
tool_call = AssistantPromptMessage.ToolCall(
|
|
id=response_function_call.get("id", ""),
|
|
type="function",
|
|
function=function,
|
|
)
|
|
|
|
return tool_call
|