Files
Maries c6f83a63e1 feat[0.4.2]: Tool OAuth (#179)
* 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>
2025-07-23 13:49:01 +08:00

517 lines
23 KiB
Python

import json
import logging
import time
from collections.abc import Generator
from copy import deepcopy
from typing import Any, cast
from pydantic import BaseModel
from dify_plugin.entities.agent import AgentInvokeMessage
from dify_plugin.entities.model import ModelFeature
from dify_plugin.entities.model.llm import (
LLMModelConfig,
LLMResult,
LLMResultChunk,
LLMUsage,
)
from dify_plugin.entities.model.message import (
AssistantPromptMessage,
PromptMessage,
PromptMessageContentType,
SystemPromptMessage,
ToolPromptMessage,
UserPromptMessage,
)
from dify_plugin.entities.provider_config import LogMetadata
from dify_plugin.entities.tool import ToolInvokeMessage, ToolProviderType
from dify_plugin.interfaces.agent import (
AgentModelConfig,
AgentStrategy,
ToolEntity,
ToolInvokeMeta,
)
class FunctionCallingParams(BaseModel):
query: str
instruction: str | None
model: AgentModelConfig
tools: list[ToolEntity] | None
maximum_iterations: int = 3
class FunctionCallingAgentStrategy(AgentStrategy):
query: str = ""
instruction: str | None = ""
@property
def _user_prompt_message(self) -> UserPromptMessage:
return UserPromptMessage(content=self.query)
@property
def _system_prompt_message(self) -> SystemPromptMessage:
return SystemPromptMessage(content=self.instruction)
def _invoke(self, parameters: dict[str, Any]) -> Generator[AgentInvokeMessage, None, None]:
"""
Run FunctionCall agent application
"""
fc_params = FunctionCallingParams(**parameters)
# init prompt messages
query = fc_params.query
self.query = query
self.instruction = fc_params.instruction
history_prompt_messages = fc_params.model.history_prompt_messages
history_prompt_messages.insert(0, self._system_prompt_message)
history_prompt_messages.append(self._user_prompt_message)
# convert tool messages
tools = fc_params.tools
tool_instances = {tool.identity.name: tool for tool in tools} if tools else {}
prompt_messages_tools = self._init_prompt_tools(tools)
# init model parameters
stream = (
ModelFeature.STREAM_TOOL_CALL in fc_params.model.entity.features
if fc_params.model.entity and fc_params.model.entity.features
else False
)
model = fc_params.model
stop = fc_params.model.completion_params.get("stop", []) if fc_params.model.completion_params else []
# init function calling state
iteration_step = 1
max_iteration_steps = fc_params.maximum_iterations
current_thoughts: list[PromptMessage] = []
function_call_state = True # continue to run until there is not any tool call
llm_usage: dict[str, LLMUsage | None] = {"usage": None}
final_answer = ""
while function_call_state and iteration_step <= max_iteration_steps:
# start a new round
function_call_state = False
round_started_at = time.perf_counter()
round_log = self.create_log_message(
label=f"ROUND {iteration_step}",
data={},
metadata={
LogMetadata.STARTED_AT: round_started_at,
},
status=ToolInvokeMessage.LogMessage.LogStatus.START,
)
yield round_log
# If max_iteration_steps=1, need to execute tool calls
if iteration_step == max_iteration_steps and max_iteration_steps > 1:
# the last iteration, remove all tools
prompt_messages_tools = []
# recalc llm max tokens
prompt_messages = self._organize_prompt_messages(
history_prompt_messages=history_prompt_messages,
current_thoughts=current_thoughts,
)
if model.entity and model.completion_params:
self.recalc_llm_max_tokens(model.entity, prompt_messages, model.completion_params)
# invoke model
model_started_at = time.perf_counter()
model_log = self.create_log_message(
label=f"{model.model} Thought",
data={},
metadata={
LogMetadata.STARTED_AT: model_started_at,
LogMetadata.PROVIDER: model.provider,
},
parent=round_log,
status=ToolInvokeMessage.LogMessage.LogStatus.START,
)
yield model_log
model_config = LLMModelConfig(**model.model_dump(mode="json"))
chunks: Generator[LLMResultChunk, None, None] | LLMResult = self.session.model.llm.invoke(
model_config=model_config,
prompt_messages=prompt_messages,
stop=stop,
stream=stream,
tools=prompt_messages_tools,
)
tool_calls: list[tuple[str, str, dict[str, Any]]] = []
# save full response
response = ""
# save tool call names and inputs
tool_call_names = ""
current_llm_usage = None
if isinstance(chunks, Generator):
for chunk in chunks:
# check if there is any tool call
if self.check_tool_calls(chunk):
function_call_state = True
tool_calls.extend(self.extract_tool_calls(chunk) or [])
tool_call_names = ";".join([tool_call[1] for tool_call in tool_calls])
if chunk.delta.message and chunk.delta.message.content:
if isinstance(chunk.delta.message.content, list):
for content in chunk.delta.message.content:
response += content.data
if not function_call_state or iteration_step == max_iteration_steps:
yield self.create_text_message(content.data)
else:
response += str(chunk.delta.message.content)
if not function_call_state or iteration_step == max_iteration_steps:
yield self.create_text_message(str(chunk.delta.message.content))
if chunk.delta.usage:
self.increase_usage(llm_usage, chunk.delta.usage)
current_llm_usage = chunk.delta.usage
else:
result = chunks
result = cast(LLMResult, result)
# check if there is any tool call
if self.check_blocking_tool_calls(result):
function_call_state = True
tool_calls.extend(self.extract_blocking_tool_calls(result) or [])
tool_call_names = ";".join([tool_call[1] for tool_call in tool_calls])
if result.usage:
self.increase_usage(llm_usage, result.usage)
current_llm_usage = result.usage
if result.message and result.message.content:
if isinstance(result.message.content, list):
for content in result.message.content:
response += content.data
else:
response += str(result.message.content)
if not result.message.content:
result.message.content = ""
if isinstance(result.message.content, str):
yield self.create_text_message(result.message.content)
elif isinstance(result.message.content, list):
for content in result.message.content:
yield self.create_text_message(content.data)
yield self.finish_log_message(
log=model_log,
data={
"output": response,
"tool_name": tool_call_names,
"tool_input": [{"name": tool_call[1], "args": tool_call[2]} for tool_call in tool_calls],
},
metadata={
LogMetadata.STARTED_AT: model_started_at,
LogMetadata.FINISHED_AT: time.perf_counter(),
LogMetadata.ELAPSED_TIME: time.perf_counter() - model_started_at,
LogMetadata.PROVIDER: model.provider,
LogMetadata.TOTAL_PRICE: current_llm_usage.total_price if current_llm_usage else 0,
LogMetadata.CURRENCY: current_llm_usage.currency if current_llm_usage else "",
LogMetadata.TOTAL_TOKENS: current_llm_usage.total_tokens if current_llm_usage else 0,
},
)
assistant_message = AssistantPromptMessage(content="", tool_calls=[])
if not tool_calls:
assistant_message.content = response
current_thoughts.append(assistant_message)
final_answer += response + "\n"
# call tools
tool_responses = []
for tool_call_id, tool_call_name, tool_call_args in tool_calls:
current_thoughts.append(
AssistantPromptMessage(
content="",
tool_calls=[
AssistantPromptMessage.ToolCall(
id=tool_call_id,
type="function",
function=AssistantPromptMessage.ToolCall.ToolCallFunction(
name=tool_call_name,
arguments=json.dumps(tool_call_args, ensure_ascii=False),
),
)
],
)
)
tool_instance = tool_instances[tool_call_name]
tool_call_started_at = time.perf_counter()
tool_call_log = self.create_log_message(
label=f"CALL {tool_call_name}",
data={},
metadata={
LogMetadata.STARTED_AT: time.perf_counter(),
LogMetadata.PROVIDER: tool_instance.identity.provider,
},
parent=round_log,
status=ToolInvokeMessage.LogMessage.LogStatus.START,
)
yield tool_call_log
if not tool_instance:
tool_response = {
"tool_call_id": tool_call_id,
"tool_call_name": tool_call_name,
"tool_response": f"there is not a tool named {tool_call_name}",
"meta": ToolInvokeMeta.error_instance(f"there is not a tool named {tool_call_name}").to_dict(),
}
else:
# invoke tool
try:
tool_invoke_responses = self.session.tool.invoke(
provider_type=ToolProviderType(tool_instance.provider_type),
provider=tool_instance.identity.provider,
tool_name=tool_instance.identity.name,
parameters={
**tool_instance.runtime_parameters,
**tool_call_args,
},
credential_id=tool_instance.credential_id,
)
tool_result = ""
for tool_invoke_response in tool_invoke_responses:
if tool_invoke_response.type == ToolInvokeMessage.MessageType.TEXT:
tool_result += cast(
ToolInvokeMessage.TextMessage,
tool_invoke_response.message,
).text
elif tool_invoke_response.type == ToolInvokeMessage.MessageType.LINK:
tool_result += (
"result link: "
+ cast(ToolInvokeMessage.TextMessage, tool_invoke_response.message).text
+ "."
+ " please tell user to check it."
)
elif tool_invoke_response.type in {
ToolInvokeMessage.MessageType.IMAGE_LINK,
ToolInvokeMessage.MessageType.IMAGE,
}:
# Extract the file path or URL from the message
if hasattr(tool_invoke_response.message, "text"):
file_info = cast(
ToolInvokeMessage.TextMessage,
tool_invoke_response.message,
).text
# Try to create a blob message with the file content
try:
# If it's a local file path, try to read it
if file_info.startswith("/files/"):
import os
if os.path.exists(file_info):
with open(file_info, "rb") as f:
file_content = f.read()
# Create a blob message with the file content
blob_response = self.create_blob_message(
blob=file_content,
meta={
"mime_type": "image/png",
"filename": os.path.basename(file_info),
},
)
yield blob_response
except Exception:
logging.exception("Failed to create blob message")
tool_result += (
"image has been created and sent to user already, "
+ "you do not need to create it, just tell the user to check it now."
)
# TODO: convert to agent invoke message
yield tool_invoke_response
elif tool_invoke_response.type == ToolInvokeMessage.MessageType.JSON:
text = json.dumps(
cast(
ToolInvokeMessage.JsonMessage,
tool_invoke_response.message,
).json_object,
ensure_ascii=False,
)
tool_result += f"tool response: {text}."
elif tool_invoke_response.type == ToolInvokeMessage.MessageType.BLOB:
tool_result += "Generated file ... "
# TODO: convert to agent invoke message
yield tool_invoke_response
else:
tool_result += f"tool response: {tool_invoke_response.message!r}."
except Exception as e:
tool_result = f"tool invoke error: {e!s}"
tool_response = {
"tool_call_id": tool_call_id,
"tool_call_name": tool_call_name,
"tool_call_input": {
**tool_instance.runtime_parameters,
**tool_call_args,
},
"tool_response": tool_result,
}
yield self.finish_log_message(
log=tool_call_log,
data={
"output": tool_response,
},
metadata={
LogMetadata.STARTED_AT: tool_call_started_at,
LogMetadata.PROVIDER: tool_instance.identity.provider,
LogMetadata.FINISHED_AT: time.perf_counter(),
LogMetadata.ELAPSED_TIME: time.perf_counter() - tool_call_started_at,
},
)
tool_responses.append(tool_response)
if tool_response["tool_response"] is not None:
current_thoughts.append(
ToolPromptMessage(
content=str(tool_response["tool_response"]),
tool_call_id=tool_call_id,
name=tool_call_name,
)
)
# update prompt tool
for prompt_tool in prompt_messages_tools:
self.update_prompt_message_tool(tool_instances[prompt_tool.name], prompt_tool)
yield self.finish_log_message(
log=round_log,
data={
"output": {
"llm_response": response,
"tool_responses": tool_responses,
},
},
metadata={
LogMetadata.STARTED_AT: round_started_at,
LogMetadata.FINISHED_AT: time.perf_counter(),
LogMetadata.ELAPSED_TIME: time.perf_counter() - round_started_at,
LogMetadata.TOTAL_PRICE: current_llm_usage.total_price if current_llm_usage else 0,
LogMetadata.CURRENCY: current_llm_usage.currency if current_llm_usage else "",
LogMetadata.TOTAL_TOKENS: current_llm_usage.total_tokens if current_llm_usage else 0,
},
)
# If max_iteration_steps=1, need to return tool responses
if tool_responses and max_iteration_steps == 1:
for resp in tool_responses:
yield self.create_text_message(str(resp["tool_response"]))
iteration_step += 1
yield self.create_json_message(
{
"execution_metadata": {
LogMetadata.TOTAL_PRICE: llm_usage["usage"].total_price if llm_usage["usage"] is not None else 0,
LogMetadata.CURRENCY: llm_usage["usage"].currency if llm_usage["usage"] is not None else "",
LogMetadata.TOTAL_TOKENS: llm_usage["usage"].total_tokens if llm_usage["usage"] is not None else 0,
}
}
)
def check_tool_calls(self, llm_result_chunk: LLMResultChunk) -> bool:
"""
Check if there is any tool call in llm result chunk
"""
return bool(llm_result_chunk.delta.message.tool_calls)
def check_blocking_tool_calls(self, llm_result: LLMResult) -> bool:
"""
Check if there is any blocking tool call in llm result
"""
return bool(llm_result.message.tool_calls)
def extract_tool_calls(self, llm_result_chunk: LLMResultChunk) -> list[tuple[str, str, dict[str, Any]]]:
"""
Extract tool calls from llm result chunk
Returns:
List[Tuple[str, str, Dict[str, Any]]]: [(tool_call_id, tool_call_name, tool_call_args)]
"""
tool_calls = []
for prompt_message in llm_result_chunk.delta.message.tool_calls:
args = {}
if prompt_message.function.arguments != "":
args = json.loads(prompt_message.function.arguments)
tool_calls.append(
(
prompt_message.id,
prompt_message.function.name,
args,
)
)
return tool_calls
def extract_blocking_tool_calls(self, llm_result: LLMResult) -> list[tuple[str, str, dict[str, Any]]]:
"""
Extract blocking tool calls from llm result
Returns:
List[Tuple[str, str, Dict[str, Any]]]: [(tool_call_id, tool_call_name, tool_call_args)]
"""
tool_calls = []
for prompt_message in llm_result.message.tool_calls:
args = {}
if prompt_message.function.arguments != "":
args = json.loads(prompt_message.function.arguments)
tool_calls.append(
(
prompt_message.id,
prompt_message.function.name,
args,
)
)
return tool_calls
def _init_system_message(self, prompt_template: str, prompt_messages: list[PromptMessage]) -> list[PromptMessage]:
"""
Initialize system message
"""
if not prompt_messages and prompt_template:
return [
SystemPromptMessage(content=prompt_template),
]
if prompt_messages and not isinstance(prompt_messages[0], SystemPromptMessage) and prompt_template:
prompt_messages.insert(0, SystemPromptMessage(content=prompt_template))
return prompt_messages or []
def _clear_user_prompt_image_messages(self, prompt_messages: list[PromptMessage]) -> list[PromptMessage]:
"""
As for now, gpt supports both fc and vision at the first iteration.
We need to remove the image messages from the prompt messages at the first iteration.
"""
prompt_messages = deepcopy(prompt_messages)
for prompt_message in prompt_messages:
if isinstance(prompt_message, UserPromptMessage) and isinstance(prompt_message.content, list):
prompt_message.content = "\n".join(
[
content.data
if content.type == PromptMessageContentType.TEXT
else "[image]"
if content.type == PromptMessageContentType.IMAGE
else "[file]"
for content in prompt_message.content
]
)
return prompt_messages
def _organize_prompt_messages(
self,
current_thoughts: list[PromptMessage],
history_prompt_messages: list[PromptMessage],
) -> list[PromptMessage]:
prompt_messages = [
*history_prompt_messages,
*current_thoughts,
]
if len(current_thoughts) != 0:
# clear messages after the first iteration
prompt_messages = self._clear_user_prompt_image_messages(prompt_messages)
return prompt_messages