mirror of
https://github.com/langgenius/dify-plugin-sdks.git
synced 2026-07-22 10:25:23 -04:00
c6f83a63e1
* chore: fix ruff issue * feat(oauth): implement OAuth * feat(invoke-message): refactor message handling and introduce InvokeMessage class * feat(plugin-oauth): add credential_id and credential_type to tool parameters * feat(plugin-oauth): add credential_id and credential_type to tool parameters * chore: update dify_plugin version to 0.5.0b4 and clean up github.yaml * chore: update plugin version to 0.1.2 in manifest.yaml * feat(session): session context and tool backwards invocation credential support * feat(oauth): session context and tool backwards invocation credential support * feat: update README and requirements for OAuth support in version 0.4.2 * feat: add .gitignore to exclude IDE files and secret keys * chore: apply ruff * feat: bump version to 0.4.2b1 * feat: update GitHub plugin configuration for OAuth support and improve credential handling * feat: update .gitignore to exclude dify plugin files and public keys * feat: fix credential validation for GitHub API and bump version to 0.2.1 * feat: update GitHub plugin to support multiple access tokens and bump version to 0.2.5 * chore: apply ruff * feat: add ToolProviderOAuthError for improved OAuth error handling in GitHub plugin * chore: apply ruff * chore: bump version to 0.4.2 * chore: update examples sdk version to 0.4.2 * fix: thread deadlock in PluginRunner when running tests without gevent monkey patching * feat: add support for refreshing OAuth credentials in Plugin and GitHub provider * feat: refactor OAuth credential handling to return structured OAuthCredentials object * apply ruff * feat: refactor OAuth credential handling to use ToolOAuthCredentials for improved structure * feat: reorganize imports in __init__.py for improved clarity and structure * feat: add Microsoft To Do plugin for refresh token example * chore: apply ruff * fix: update author in GitHub configuration and clean up Microsoft To Do schema * chore: bump version to 0.4.2b2 in pyproject.toml * feat: update Microsoft To Do plugin to handle OAuth token encoding and version bump * feat:remove inelegant example * chore: update dify_plugin version to 0.4.2 * chore: bump version to 0.4.2 in pyproject.toml --------- Co-authored-by: Yeuoly <admin@srmxy.cn>
1189 lines
44 KiB
Python
1189 lines
44 KiB
Python
import logging
|
|
from collections.abc import Generator
|
|
from decimal import Decimal
|
|
from typing import Union, cast
|
|
|
|
import tiktoken
|
|
from openai import OpenAI, Stream
|
|
from openai.types import Completion
|
|
from openai.types.chat import (
|
|
ChatCompletion,
|
|
ChatCompletionChunk,
|
|
ChatCompletionMessageToolCall,
|
|
)
|
|
from openai.types.chat.chat_completion_chunk import (
|
|
ChoiceDeltaFunctionCall,
|
|
ChoiceDeltaToolCall,
|
|
)
|
|
from openai.types.chat.chat_completion_message import FunctionCall
|
|
|
|
from dify_plugin import LargeLanguageModel
|
|
from dify_plugin.entities import I18nObject
|
|
from dify_plugin.entities.model import (
|
|
AIModelEntity,
|
|
FetchFrom,
|
|
ModelType,
|
|
PriceConfig,
|
|
)
|
|
from dify_plugin.entities.model.llm import (
|
|
LLMMode,
|
|
LLMResult,
|
|
LLMResultChunk,
|
|
LLMResultChunkDelta,
|
|
)
|
|
from dify_plugin.entities.model.message import (
|
|
AssistantPromptMessage,
|
|
ImagePromptMessageContent,
|
|
PromptMessage,
|
|
PromptMessageContentType,
|
|
PromptMessageTool,
|
|
SystemPromptMessage,
|
|
TextPromptMessageContent,
|
|
ToolPromptMessage,
|
|
UserPromptMessage,
|
|
)
|
|
from dify_plugin.errors.model import (
|
|
CredentialsValidateFailedError,
|
|
)
|
|
|
|
from ..common_openai import _CommonOpenAI
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
OPENAI_BLOCK_MODE_PROMPT = """You should always follow the instructions and output a valid {{block}} object.
|
|
The structure of the {{block}} object you can found in the instructions, use {"answer": "$your_answer"} as the default structure
|
|
if you are not sure about the structure.
|
|
|
|
<instructions>
|
|
{{instructions}}
|
|
</instructions>
|
|
""" # noqa: E501
|
|
|
|
|
|
class OpenAILargeLanguageModel(_CommonOpenAI, 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
|
|
"""
|
|
# handle fine tune remote models
|
|
base_model = model
|
|
if model.startswith("ft:"):
|
|
base_model = model.split(":")[1]
|
|
|
|
# get model mode
|
|
model_mode = self.get_model_mode(base_model, credentials)
|
|
|
|
if model_mode == LLMMode.CHAT:
|
|
# chat model
|
|
return self._chat_generate(
|
|
model=model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
model_parameters=model_parameters,
|
|
tools=tools,
|
|
stop=stop,
|
|
stream=stream,
|
|
user=user,
|
|
)
|
|
else:
|
|
# text completion model
|
|
return self._generate(
|
|
model=model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
model_parameters=model_parameters,
|
|
stop=stop,
|
|
stream=stream,
|
|
user=user,
|
|
)
|
|
|
|
def _code_block_mode_wrapper(
|
|
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]:
|
|
"""
|
|
Code block mode wrapper for invoking large language model
|
|
"""
|
|
# handle fine tune remote models
|
|
base_model = model
|
|
if model.startswith("ft:"):
|
|
base_model = model.split(":")[1]
|
|
|
|
# get model mode
|
|
model_mode = self.get_model_mode(base_model, credentials)
|
|
|
|
# transform response format
|
|
if "response_format" in model_parameters and model_parameters["response_format"] in ["JSON", "XML"]:
|
|
stop = stop or []
|
|
if model_mode == LLMMode.CHAT:
|
|
# chat model
|
|
self._transform_chat_json_prompts(
|
|
model=base_model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
model_parameters=model_parameters,
|
|
tools=tools,
|
|
stop=stop,
|
|
stream=stream,
|
|
user=user,
|
|
response_format=model_parameters["response_format"],
|
|
)
|
|
else:
|
|
self._transform_completion_json_prompts(
|
|
model=base_model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
model_parameters=model_parameters,
|
|
tools=tools,
|
|
stop=stop,
|
|
stream=stream,
|
|
user=user,
|
|
response_format=model_parameters["response_format"],
|
|
)
|
|
model_parameters.pop("response_format")
|
|
|
|
return self._invoke(
|
|
model=model,
|
|
credentials=credentials,
|
|
prompt_messages=prompt_messages,
|
|
model_parameters=model_parameters,
|
|
tools=tools,
|
|
stop=stop,
|
|
stream=stream,
|
|
user=user,
|
|
)
|
|
|
|
def _transform_chat_json_prompts(
|
|
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,
|
|
response_format: str = "JSON",
|
|
) -> None:
|
|
"""
|
|
Transform json prompts
|
|
"""
|
|
stop = stop or []
|
|
|
|
if "```\n" not in stop:
|
|
stop.append("```\n")
|
|
if "\n```" not in stop:
|
|
stop.append("\n```")
|
|
|
|
# check if there is a system message
|
|
if len(prompt_messages) > 0 and isinstance(prompt_messages[0], SystemPromptMessage):
|
|
assert isinstance(prompt_messages[0].content, str)
|
|
# override the system message
|
|
prompt_messages[0] = SystemPromptMessage(
|
|
content=OPENAI_BLOCK_MODE_PROMPT.replace("{{instructions}}", prompt_messages[0].content).replace(
|
|
"{{block}}", response_format
|
|
)
|
|
)
|
|
prompt_messages.append(AssistantPromptMessage(content=f"\n```{response_format}\n"))
|
|
else:
|
|
# insert the system message
|
|
prompt_messages.insert(
|
|
0,
|
|
SystemPromptMessage(
|
|
content=OPENAI_BLOCK_MODE_PROMPT.replace(
|
|
"{{instructions}}",
|
|
f"Please output a valid {response_format} object.",
|
|
).replace("{{block}}", response_format)
|
|
),
|
|
)
|
|
prompt_messages.append(AssistantPromptMessage(content=f"\n```{response_format}"))
|
|
|
|
def _transform_completion_json_prompts(
|
|
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,
|
|
response_format: str = "JSON",
|
|
) -> None:
|
|
"""
|
|
Transform json prompts
|
|
"""
|
|
stop = stop or []
|
|
|
|
if "```\n" not in stop:
|
|
stop.append("```\n")
|
|
if "\n```" not in stop:
|
|
stop.append("\n```")
|
|
|
|
# override the last user message
|
|
user_message = None
|
|
i = None
|
|
for i in range(len(prompt_messages) - 1, -1, -1):
|
|
if isinstance(prompt_messages[i], UserPromptMessage):
|
|
user_message = prompt_messages[i]
|
|
break
|
|
|
|
assert isinstance(i, int)
|
|
|
|
if user_message:
|
|
assert isinstance(prompt_messages, list)
|
|
assert isinstance(prompt_messages[i], PromptMessage)
|
|
content = prompt_messages[i].content
|
|
assert isinstance(content, str)
|
|
|
|
if content[-11:] == "Assistant: ":
|
|
assert isinstance(user_message.content, str)
|
|
# now we are in the chat app, remove the last assistant message
|
|
prompt_messages[i].content = content[:-11]
|
|
prompt_messages[i] = UserPromptMessage(
|
|
content=OPENAI_BLOCK_MODE_PROMPT.replace("{{instructions}}", user_message.content).replace(
|
|
"{{block}}", response_format
|
|
)
|
|
)
|
|
prompt_messages[i].content += f"Assistant:\n```{response_format}\n" # type: ignore
|
|
else:
|
|
assert isinstance(user_message.content, str)
|
|
|
|
prompt_messages[i] = UserPromptMessage(
|
|
content=OPENAI_BLOCK_MODE_PROMPT.replace("{{instructions}}", user_message.content).replace(
|
|
"{{block}}", response_format
|
|
)
|
|
)
|
|
|
|
prompt_messages[i].content += f"\n```{response_format}\n" # type: ignore
|
|
|
|
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: model name
|
|
:param credentials: model credentials
|
|
:param prompt_messages: prompt messages
|
|
:param tools: tools for tool calling
|
|
:return:
|
|
"""
|
|
# handle fine tune remote models
|
|
base_model = model.removeprefix("ft:")
|
|
|
|
# get model mode
|
|
model_mode = self.get_model_mode(model)
|
|
|
|
if model_mode == LLMMode.CHAT:
|
|
# chat model
|
|
return self._num_tokens_from_messages(base_model, prompt_messages, tools)
|
|
else:
|
|
# text completion model, do not support tool calling
|
|
content = prompt_messages[0].content
|
|
assert isinstance(content, str)
|
|
return self._num_tokens_from_string(base_model, content)
|
|
|
|
def validate_credentials(self, model: str, credentials: dict) -> None:
|
|
"""
|
|
Validate model credentials
|
|
|
|
:param model: model name
|
|
:param credentials: model credentials
|
|
:return:
|
|
"""
|
|
try:
|
|
# transform credentials to kwargs for model instance
|
|
credentials_kwargs = self._to_credential_kwargs(credentials)
|
|
client = OpenAI(**credentials_kwargs)
|
|
|
|
# handle fine tune remote models
|
|
base_model = model
|
|
# fine-tuned model name likes ft:gpt-3.5-turbo-0613:personal::xxxxx
|
|
if model.startswith("ft:"):
|
|
base_model = model.split(":")[1]
|
|
|
|
# check if model exists
|
|
remote_models = self.remote_models(credentials)
|
|
remote_model_map = {model.model: model for model in remote_models}
|
|
if model not in remote_model_map:
|
|
raise CredentialsValidateFailedError(f"Fine-tuned model {model} not found")
|
|
|
|
# get model mode
|
|
model_mode = self.get_model_mode(base_model, credentials)
|
|
|
|
if model_mode == LLMMode.CHAT:
|
|
# chat model
|
|
client.chat.completions.create(
|
|
messages=[{"role": "user", "content": "ping"}],
|
|
model=model,
|
|
temperature=0,
|
|
max_tokens=20,
|
|
stream=False,
|
|
)
|
|
else:
|
|
# text completion model
|
|
client.completions.create(
|
|
prompt="ping",
|
|
model=model,
|
|
temperature=0,
|
|
max_tokens=20,
|
|
stream=False,
|
|
)
|
|
except Exception as ex:
|
|
raise CredentialsValidateFailedError(str(ex)) from ex
|
|
|
|
def remote_models(self, credentials: dict) -> list[AIModelEntity]:
|
|
"""
|
|
Return remote models if credentials are provided.
|
|
|
|
:param credentials: provider credentials
|
|
:return:
|
|
"""
|
|
# get predefined models
|
|
predefined_models = self.predefined_models()
|
|
predefined_models_map = {model.model: model for model in predefined_models}
|
|
|
|
# transform credentials to kwargs for model instance
|
|
credentials_kwargs = self._to_credential_kwargs(credentials)
|
|
client = OpenAI(**credentials_kwargs)
|
|
|
|
# get all remote models
|
|
remote_models = client.models.list()
|
|
|
|
fine_tune_models = [model for model in remote_models if model.id.startswith("ft:")]
|
|
|
|
ai_model_entities = []
|
|
for model in fine_tune_models:
|
|
base_model = model.id.split(":")[1]
|
|
|
|
base_model_schema = None
|
|
for (
|
|
predefined_model_name,
|
|
predefined_model,
|
|
) in predefined_models_map.items():
|
|
if predefined_model_name in base_model:
|
|
base_model_schema = predefined_model
|
|
|
|
if not base_model_schema:
|
|
continue
|
|
|
|
ai_model_entity = AIModelEntity(
|
|
model=model.id,
|
|
label=I18nObject(zh_Hans=model.id, en_US=model.id),
|
|
model_type=ModelType.LLM,
|
|
features=base_model_schema.features,
|
|
fetch_from=FetchFrom.CUSTOMIZABLE_MODEL,
|
|
model_properties=base_model_schema.model_properties,
|
|
parameter_rules=base_model_schema.parameter_rules,
|
|
pricing=PriceConfig(
|
|
input=Decimal("0.003"),
|
|
output=Decimal("0.006"),
|
|
unit=Decimal("0.001"),
|
|
currency="USD",
|
|
),
|
|
)
|
|
|
|
ai_model_entities.append(ai_model_entity)
|
|
|
|
return ai_model_entities
|
|
|
|
def _generate(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
prompt_messages: list[PromptMessage],
|
|
model_parameters: dict,
|
|
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
|
|
"""
|
|
# transform credentials to kwargs for model instance
|
|
credentials_kwargs = self._to_credential_kwargs(credentials)
|
|
|
|
# init model client
|
|
client = OpenAI(**credentials_kwargs)
|
|
|
|
extra_model_kwargs = {}
|
|
|
|
if stop:
|
|
extra_model_kwargs["stop"] = stop
|
|
|
|
if user:
|
|
extra_model_kwargs["user"] = user
|
|
|
|
if stream:
|
|
extra_model_kwargs["stream_options"] = {"include_usage": True}
|
|
|
|
# text completion model
|
|
assert isinstance(prompt_messages[0].content, str)
|
|
|
|
response = client.completions.create(
|
|
prompt=prompt_messages[0].content,
|
|
model=model,
|
|
stream=stream,
|
|
**model_parameters,
|
|
**extra_model_kwargs,
|
|
)
|
|
|
|
if stream:
|
|
assert isinstance(response, Stream)
|
|
return self._handle_generate_stream_response(model, credentials, response, prompt_messages)
|
|
|
|
assert isinstance(response, Completion)
|
|
return self._handle_generate_response(model, credentials, response, prompt_messages)
|
|
|
|
def _handle_generate_response(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
response: Completion,
|
|
prompt_messages: list[PromptMessage],
|
|
) -> LLMResult:
|
|
"""
|
|
Handle llm completion response
|
|
|
|
:param model: model name
|
|
:param credentials: model credentials
|
|
:param response: response
|
|
:param prompt_messages: prompt messages
|
|
:return: llm result
|
|
"""
|
|
assistant_text = response.choices[0].text
|
|
|
|
# transform assistant message to prompt message
|
|
assistant_prompt_message = AssistantPromptMessage(content=assistant_text)
|
|
|
|
# calculate num tokens
|
|
if response.usage:
|
|
# transform usage
|
|
prompt_tokens = response.usage.prompt_tokens
|
|
completion_tokens = response.usage.completion_tokens
|
|
else:
|
|
# calculate num tokens
|
|
assert isinstance(prompt_messages[0].content, str)
|
|
prompt_tokens = self._num_tokens_from_string(model, prompt_messages[0].content)
|
|
completion_tokens = self._num_tokens_from_string(model, assistant_text)
|
|
|
|
# transform usage
|
|
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
|
|
|
|
# transform response
|
|
result = LLMResult(
|
|
model=response.model,
|
|
message=assistant_prompt_message,
|
|
usage=usage,
|
|
system_fingerprint=response.system_fingerprint,
|
|
)
|
|
|
|
return result
|
|
|
|
def _handle_generate_stream_response(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
response: Stream[Completion],
|
|
prompt_messages: list[PromptMessage],
|
|
) -> Generator:
|
|
"""
|
|
Handle llm completion stream response
|
|
|
|
:param model: model name
|
|
:param credentials: model credentials
|
|
:param response: response
|
|
:param prompt_messages: prompt messages
|
|
:return: llm response chunk generator result
|
|
"""
|
|
full_text = ""
|
|
prompt_tokens = 0
|
|
completion_tokens = 0
|
|
|
|
final_chunk = LLMResultChunk(
|
|
model=model,
|
|
delta=LLMResultChunkDelta(
|
|
index=0,
|
|
message=AssistantPromptMessage(content=""),
|
|
),
|
|
)
|
|
|
|
for chunk in response:
|
|
if len(chunk.choices) == 0:
|
|
if chunk.usage:
|
|
# calculate num tokens
|
|
prompt_tokens = chunk.usage.prompt_tokens
|
|
completion_tokens = chunk.usage.completion_tokens
|
|
continue
|
|
|
|
delta = chunk.choices[0]
|
|
|
|
if delta.finish_reason is None and (delta.text is None or delta.text == ""):
|
|
continue
|
|
|
|
# transform assistant message to prompt message
|
|
text = delta.text or ""
|
|
assistant_prompt_message = AssistantPromptMessage(content=text)
|
|
|
|
full_text += text
|
|
|
|
if delta.finish_reason is not None:
|
|
final_chunk = LLMResultChunk(
|
|
model=chunk.model,
|
|
system_fingerprint=chunk.system_fingerprint,
|
|
delta=LLMResultChunkDelta(
|
|
index=delta.index,
|
|
message=assistant_prompt_message,
|
|
finish_reason=delta.finish_reason,
|
|
),
|
|
)
|
|
else:
|
|
yield LLMResultChunk(
|
|
model=chunk.model,
|
|
system_fingerprint=chunk.system_fingerprint,
|
|
delta=LLMResultChunkDelta(
|
|
index=delta.index,
|
|
message=assistant_prompt_message,
|
|
),
|
|
)
|
|
|
|
if not prompt_tokens:
|
|
assert isinstance(prompt_messages[0].content, str)
|
|
prompt_tokens = self._num_tokens_from_string(model, prompt_messages[0].content)
|
|
|
|
if not completion_tokens:
|
|
completion_tokens = self._num_tokens_from_string(model, full_text)
|
|
|
|
# transform usage
|
|
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
|
|
|
|
final_chunk.delta.usage = usage
|
|
|
|
yield final_chunk
|
|
|
|
def _chat_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 chat model
|
|
|
|
:param model: model name
|
|
:param credentials: 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
|
|
"""
|
|
# transform credentials to kwargs for model instance
|
|
credentials_kwargs = self._to_credential_kwargs(credentials)
|
|
|
|
# init model client
|
|
client = OpenAI(**credentials_kwargs)
|
|
|
|
response_format = model_parameters.get("response_format")
|
|
if response_format:
|
|
response_format = {"type": "json_object"} if response_format == "json_object" else {"type": "text"}
|
|
|
|
model_parameters["response_format"] = response_format
|
|
|
|
extra_model_kwargs = {}
|
|
|
|
if tools:
|
|
# extra_model_kwargs['tools'] = [helper.dump_model(PromptMessageFunction(function=tool)) for tool in tools]
|
|
extra_model_kwargs["functions"] = [
|
|
{
|
|
"name": tool.name,
|
|
"description": tool.description,
|
|
"parameters": tool.parameters,
|
|
}
|
|
for tool in tools
|
|
]
|
|
|
|
if stop:
|
|
extra_model_kwargs["stop"] = stop
|
|
|
|
if user:
|
|
extra_model_kwargs["user"] = user
|
|
|
|
if stream:
|
|
extra_model_kwargs["stream_options"] = {"include_usage": True}
|
|
|
|
# clear illegal prompt messages
|
|
prompt_messages = self._clear_illegal_prompt_messages(model, prompt_messages)
|
|
|
|
# chat model
|
|
response = client.chat.completions.create(
|
|
messages=[self._convert_prompt_message_to_dict(m) for m in prompt_messages], # type: ignore
|
|
model=model,
|
|
stream=stream,
|
|
**model_parameters,
|
|
**extra_model_kwargs,
|
|
) # type: ignore
|
|
|
|
if stream:
|
|
return self._handle_chat_generate_stream_response(model, credentials, response, prompt_messages, tools)
|
|
|
|
return self._handle_chat_generate_response(model, credentials, response, prompt_messages, tools)
|
|
|
|
def _handle_chat_generate_response(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
response: ChatCompletion,
|
|
prompt_messages: list[PromptMessage],
|
|
tools: list[PromptMessageTool] | None = None,
|
|
) -> LLMResult:
|
|
"""
|
|
Handle llm chat response
|
|
|
|
:param model: model name
|
|
:param credentials: credentials
|
|
:param response: response
|
|
:param prompt_messages: prompt messages
|
|
:param tools: tools for tool calling
|
|
:return: llm response
|
|
"""
|
|
assistant_message = response.choices[0].message
|
|
# assistant_message_tool_calls = assistant_message.tool_calls
|
|
assistant_message_function_call = assistant_message.function_call
|
|
|
|
# extract tool calls from response
|
|
# tool_calls = self._extract_response_tool_calls(assistant_message_tool_calls)
|
|
function_call = self._extract_response_function_call(assistant_message_function_call)
|
|
tool_calls = [function_call] if function_call else []
|
|
|
|
# transform assistant message to prompt message
|
|
assistant_prompt_message = AssistantPromptMessage(content=assistant_message.content, tool_calls=tool_calls)
|
|
|
|
# calculate num tokens
|
|
if response.usage:
|
|
# transform usage
|
|
prompt_tokens = response.usage.prompt_tokens
|
|
completion_tokens = response.usage.completion_tokens
|
|
else:
|
|
# calculate num tokens
|
|
prompt_tokens = self._num_tokens_from_messages(model, prompt_messages, tools)
|
|
completion_tokens = self._num_tokens_from_messages(model, [assistant_prompt_message])
|
|
|
|
# transform usage
|
|
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
|
|
|
|
# transform response
|
|
return LLMResult(
|
|
model=response.model,
|
|
message=assistant_prompt_message,
|
|
usage=usage,
|
|
system_fingerprint=response.system_fingerprint,
|
|
)
|
|
|
|
def _handle_chat_generate_stream_response(
|
|
self,
|
|
model: str,
|
|
credentials: dict,
|
|
response: Stream[ChatCompletionChunk],
|
|
prompt_messages: list[PromptMessage],
|
|
tools: list[PromptMessageTool] | None = None,
|
|
) -> Generator:
|
|
"""
|
|
Handle llm chat stream response
|
|
|
|
:param model: model name
|
|
:param response: response
|
|
:param prompt_messages: prompt messages
|
|
:param tools: tools for tool calling
|
|
:return: llm response chunk generator
|
|
"""
|
|
full_assistant_content = ""
|
|
delta_assistant_message_function_call_storage: ChoiceDeltaFunctionCall | None = None
|
|
prompt_tokens = 0
|
|
completion_tokens = 0
|
|
final_tool_calls = []
|
|
final_chunk = LLMResultChunk(
|
|
model=model,
|
|
delta=LLMResultChunkDelta(
|
|
index=0,
|
|
message=AssistantPromptMessage(content=""),
|
|
),
|
|
)
|
|
|
|
for chunk in response:
|
|
if len(chunk.choices) == 0:
|
|
if chunk.usage:
|
|
# calculate num tokens
|
|
prompt_tokens = chunk.usage.prompt_tokens
|
|
completion_tokens = chunk.usage.completion_tokens
|
|
continue
|
|
|
|
delta = chunk.choices[0]
|
|
has_finish_reason = delta.finish_reason is not None
|
|
|
|
if (
|
|
not has_finish_reason
|
|
and (delta.delta.content is None or delta.delta.content == "")
|
|
and delta.delta.function_call is None
|
|
):
|
|
continue
|
|
|
|
# assistant_message_tool_calls = delta.delta.tool_calls
|
|
assistant_message_function_call = delta.delta.function_call
|
|
|
|
# extract tool calls from response
|
|
if delta_assistant_message_function_call_storage is not None:
|
|
# handle process of stream function call
|
|
if assistant_message_function_call:
|
|
# message has not ended ever
|
|
assert isinstance(delta_assistant_message_function_call_storage.arguments, str)
|
|
assert isinstance(assistant_message_function_call.arguments, str)
|
|
|
|
delta_assistant_message_function_call_storage.arguments += assistant_message_function_call.arguments
|
|
continue
|
|
else:
|
|
# message has ended
|
|
assistant_message_function_call = delta_assistant_message_function_call_storage
|
|
delta_assistant_message_function_call_storage = None
|
|
else:
|
|
if assistant_message_function_call:
|
|
# start of stream function call
|
|
delta_assistant_message_function_call_storage = assistant_message_function_call
|
|
if delta_assistant_message_function_call_storage.arguments is None:
|
|
delta_assistant_message_function_call_storage.arguments = ""
|
|
if not has_finish_reason:
|
|
continue
|
|
|
|
# tool_calls = self._extract_response_tool_calls(assistant_message_tool_calls)
|
|
function_call = self._extract_response_function_call(assistant_message_function_call)
|
|
tool_calls = [function_call] if function_call else []
|
|
if tool_calls:
|
|
final_tool_calls.extend(tool_calls)
|
|
|
|
# transform assistant message to prompt message
|
|
assistant_prompt_message = AssistantPromptMessage(
|
|
content=delta.delta.content or "",
|
|
tool_calls=tool_calls,
|
|
)
|
|
|
|
full_assistant_content += delta.delta.content or ""
|
|
|
|
if has_finish_reason:
|
|
final_chunk = LLMResultChunk(
|
|
model=chunk.model,
|
|
system_fingerprint=chunk.system_fingerprint,
|
|
delta=LLMResultChunkDelta(
|
|
index=delta.index,
|
|
message=assistant_prompt_message,
|
|
finish_reason=delta.finish_reason,
|
|
),
|
|
)
|
|
else:
|
|
yield LLMResultChunk(
|
|
model=chunk.model,
|
|
system_fingerprint=chunk.system_fingerprint,
|
|
delta=LLMResultChunkDelta(
|
|
index=delta.index,
|
|
message=assistant_prompt_message,
|
|
),
|
|
)
|
|
|
|
if not prompt_tokens:
|
|
prompt_tokens = self._num_tokens_from_messages(model, prompt_messages, tools)
|
|
|
|
if not completion_tokens:
|
|
full_assistant_prompt_message = AssistantPromptMessage(
|
|
content=full_assistant_content, tool_calls=final_tool_calls
|
|
)
|
|
completion_tokens = self._num_tokens_from_messages(model, [full_assistant_prompt_message])
|
|
|
|
# transform usage
|
|
usage = self._calc_response_usage(model, credentials, prompt_tokens, completion_tokens)
|
|
final_chunk.delta.usage = usage
|
|
|
|
yield final_chunk
|
|
|
|
def _extract_response_tool_calls(
|
|
self,
|
|
response_tool_calls: list[ChatCompletionMessageToolCall | ChoiceDeltaToolCall],
|
|
) -> 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:
|
|
assert isinstance(
|
|
response_tool_call,
|
|
(ChatCompletionMessageToolCall, ChoiceDeltaToolCall),
|
|
)
|
|
if response_tool_call.function:
|
|
function = AssistantPromptMessage.ToolCall.ToolCallFunction(
|
|
name=response_tool_call.function.name or "",
|
|
arguments=response_tool_call.function.arguments or "",
|
|
)
|
|
|
|
tool_call = AssistantPromptMessage.ToolCall(
|
|
id=response_tool_call.id or "",
|
|
type=response_tool_call.type or "",
|
|
function=function,
|
|
)
|
|
tool_calls.append(tool_call)
|
|
|
|
return tool_calls
|
|
|
|
def _extract_response_function_call(
|
|
self, response_function_call: FunctionCall | ChoiceDeltaFunctionCall | None
|
|
) -> 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:
|
|
assert isinstance(response_function_call, (FunctionCall, ChoiceDeltaFunctionCall))
|
|
|
|
function = AssistantPromptMessage.ToolCall.ToolCallFunction(
|
|
name=response_function_call.name or "",
|
|
arguments=response_function_call.arguments or "",
|
|
)
|
|
|
|
tool_call = AssistantPromptMessage.ToolCall(
|
|
id=response_function_call.name or "", type="function", function=function
|
|
)
|
|
|
|
return tool_call
|
|
|
|
def _clear_illegal_prompt_messages(self, model: str, prompt_messages: list[PromptMessage]) -> list[PromptMessage]:
|
|
"""
|
|
Clear illegal prompt messages for OpenAI API
|
|
|
|
:param model: model name
|
|
:param prompt_messages: prompt messages
|
|
:return: cleaned prompt messages
|
|
"""
|
|
checklist = ["gpt-4-turbo", "gpt-4-turbo-2024-04-09"]
|
|
|
|
if model in checklist:
|
|
# count how many user messages are there
|
|
user_message_count = len([m for m in prompt_messages if isinstance(m, UserPromptMessage)])
|
|
if user_message_count > 1:
|
|
for prompt_message in prompt_messages:
|
|
if isinstance(prompt_message, UserPromptMessage) and isinstance(prompt_message.content, list):
|
|
prompt_message.content = "\n".join(
|
|
[
|
|
item.data
|
|
if item.type == PromptMessageContentType.TEXT
|
|
else "[IMAGE]"
|
|
if item.type == PromptMessageContentType.IMAGE
|
|
else ""
|
|
for item in prompt_message.content
|
|
]
|
|
)
|
|
|
|
return prompt_messages
|
|
|
|
def _convert_prompt_message_to_dict(self, message: PromptMessage) -> dict:
|
|
"""
|
|
Convert PromptMessage to dict for OpenAI API
|
|
"""
|
|
if isinstance(message, UserPromptMessage):
|
|
message = cast(UserPromptMessage, message)
|
|
if isinstance(message.content, str):
|
|
message_dict = {"role": "user", "content": message.content}
|
|
else:
|
|
sub_messages = []
|
|
assert isinstance(message.content, list)
|
|
for message_content in message.content:
|
|
if message_content.type == PromptMessageContentType.TEXT:
|
|
message_content = cast(TextPromptMessageContent, 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:
|
|
# message_dict["tool_calls"] = [tool_call.dict() for tool_call in
|
|
# message.tool_calls]
|
|
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)
|
|
# message_dict = {
|
|
# "role": "tool",
|
|
# "content": message.content,
|
|
# "tool_call_id": message.tool_call_id
|
|
# }
|
|
message_dict = {
|
|
"role": "function",
|
|
"content": message.content,
|
|
"name": message.tool_call_id,
|
|
}
|
|
else:
|
|
raise ValueError(f"Got unknown type {message}")
|
|
|
|
if message.name:
|
|
message_dict["name"] = message.name
|
|
|
|
return message_dict
|
|
|
|
def _num_tokens_from_string(self, model: str, text: str, tools: list[PromptMessageTool] | None = None) -> int:
|
|
"""
|
|
Calculate num tokens for text completion model with tiktoken package.
|
|
|
|
:param model: model name
|
|
:param text: prompt text
|
|
:param tools: tools for tool calling
|
|
:return: number of tokens
|
|
"""
|
|
try:
|
|
encoding = tiktoken.encoding_for_model(model)
|
|
except KeyError:
|
|
encoding = tiktoken.get_encoding("cl100k_base")
|
|
|
|
num_tokens = len(encoding.encode(text))
|
|
|
|
if tools:
|
|
num_tokens += self._num_tokens_for_tools(encoding, tools)
|
|
|
|
return num_tokens
|
|
|
|
def _num_tokens_from_messages(
|
|
self,
|
|
model: str,
|
|
messages: list[PromptMessage],
|
|
tools: list[PromptMessageTool] | None = None,
|
|
) -> int:
|
|
"""Calculate num tokens for gpt-3.5-turbo and gpt-4 with tiktoken package.
|
|
|
|
Official documentation: https://github.com/openai/openai-cookbook/blob/
|
|
main/examples/How_to_format_inputs_to_ChatGPT_models.ipynb"""
|
|
if model.startswith("ft:"):
|
|
model = model.split(":")[1]
|
|
|
|
try:
|
|
encoding = tiktoken.encoding_for_model(model)
|
|
except KeyError:
|
|
logger.warning("Warning: model not found. Using cl100k_base encoding.")
|
|
model = "cl100k_base"
|
|
encoding = tiktoken.get_encoding(model)
|
|
|
|
if model.startswith("gpt-3.5-turbo-0301"):
|
|
# every message follows <im_start>{role/name}\n{content}<im_end>\n
|
|
tokens_per_message = 4
|
|
# if there's a name, the role is omitted
|
|
tokens_per_name = -1
|
|
elif model.startswith("gpt-3.5-turbo") or model.startswith("gpt-4"):
|
|
tokens_per_message = 3
|
|
tokens_per_name = 1
|
|
else:
|
|
raise NotImplementedError(
|
|
f"get_num_tokens_from_messages() is not presently implemented "
|
|
f"for model {model}."
|
|
"See https://github.com/openai/openai-python/blob/main/chatml.md for "
|
|
"information on how messages are converted to tokens."
|
|
)
|
|
num_tokens = 0
|
|
messages_dict = [self._convert_prompt_message_to_dict(m) 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:
|
|
for t_key, t_value in tool_call.items(): # type: ignore
|
|
num_tokens += len(encoding.encode(t_key))
|
|
if t_key == "function":
|
|
for f_key, f_value in t_value.items():
|
|
num_tokens += len(encoding.encode(f_key))
|
|
num_tokens += len(encoding.encode(f_value))
|
|
else:
|
|
num_tokens += len(encoding.encode(t_key))
|
|
num_tokens += len(encoding.encode(t_value))
|
|
else:
|
|
num_tokens += len(encoding.encode(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(encoding, tools)
|
|
|
|
return num_tokens
|
|
|
|
def _num_tokens_for_tools(self, encoding: tiktoken.Encoding, tools: list[PromptMessageTool]) -> int:
|
|
"""
|
|
Calculate num tokens for tool calling with tiktoken package.
|
|
|
|
:param encoding: encoding
|
|
:param tools: tools for tool calling
|
|
:return: number of tokens
|
|
"""
|
|
num_tokens = 0
|
|
for tool in tools:
|
|
num_tokens += len(encoding.encode("type"))
|
|
num_tokens += len(encoding.encode("function"))
|
|
|
|
# calculate num tokens for function object
|
|
num_tokens += len(encoding.encode("name"))
|
|
num_tokens += len(encoding.encode(tool.name))
|
|
num_tokens += len(encoding.encode("description"))
|
|
num_tokens += len(encoding.encode(tool.description))
|
|
parameters = tool.parameters
|
|
num_tokens += len(encoding.encode("parameters"))
|
|
if "title" in parameters:
|
|
num_tokens += len(encoding.encode("title"))
|
|
num_tokens += len(encoding.encode(parameters.get("title"))) # type: ignore
|
|
num_tokens += len(encoding.encode("type"))
|
|
num_tokens += len(encoding.encode(parameters.get("type"))) # type: ignore
|
|
if "properties" in parameters:
|
|
num_tokens += len(encoding.encode("properties"))
|
|
for key, value in parameters.get("properties").items(): # type: ignore
|
|
num_tokens += len(encoding.encode(key))
|
|
for field_key, field_value in value.items():
|
|
num_tokens += len(encoding.encode(field_key))
|
|
if field_key == "enum":
|
|
for enum_field in field_value:
|
|
num_tokens += 3
|
|
num_tokens += len(encoding.encode(enum_field))
|
|
else:
|
|
num_tokens += len(encoding.encode(field_key))
|
|
num_tokens += len(encoding.encode(str(field_value)))
|
|
if "required" in parameters:
|
|
num_tokens += len(encoding.encode("required"))
|
|
for required_field in parameters["required"]:
|
|
num_tokens += 3
|
|
num_tokens += len(encoding.encode(required_field))
|
|
|
|
return num_tokens
|
|
|
|
def get_customizable_model_schema(self, model: str, credentials: dict) -> AIModelEntity:
|
|
"""
|
|
OpenAI supports fine-tuning of their models. This method returns the schema of the base model
|
|
but renamed to the fine-tuned model name.
|
|
|
|
:param model: model name
|
|
:param credentials: credentials
|
|
|
|
:return: model schema
|
|
"""
|
|
base_model = model.removeprefix("ft:")
|
|
|
|
# get model schema
|
|
models = self.predefined_models()
|
|
model_map = {model.model: model for model in models}
|
|
if base_model not in model_map:
|
|
raise ValueError(f"Base model {base_model} not found")
|
|
|
|
base_model_schema = model_map[base_model]
|
|
|
|
base_model_schema_features = base_model_schema.features or []
|
|
base_model_schema_model_properties = base_model_schema.model_properties or {}
|
|
base_model_schema_parameters_rules = base_model_schema.parameter_rules or []
|
|
|
|
entity = AIModelEntity(
|
|
model=model,
|
|
label=I18nObject(zh_Hans=model, en_US=model),
|
|
model_type=ModelType.LLM,
|
|
features=list(base_model_schema_features),
|
|
fetch_from=FetchFrom.CUSTOMIZABLE_MODEL,
|
|
model_properties=dict(base_model_schema_model_properties.items()),
|
|
parameter_rules=list(base_model_schema_parameters_rules),
|
|
pricing=base_model_schema.pricing,
|
|
)
|
|
|
|
return entity
|