Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,25 @@ Use `message.get_text_content()` when a consumer needs plain text, and retain th
`ToolPromptMessage.is_empty()` treats a tool call ID as content, including when the tool result is empty.
The invoking Agent/plugin, provider, daemon, and host must all preserve these fields for replay across tool turns.

## OpenAI-Compatible Completion Input

In SDK 0.11.0 (unreleased), the shared `OAICompatLargeLanguageModel` completion mode accepts text from the first prompt message.
Text content blocks are concatenated in order into one prompt string, including empty lists as an empty string.
Image, audio, video, or document blocks in that prompt raise an SDK invocation error before sending a request, including when mixed with text.
For multimodal input, use chat mode with a model that supports the input modality and message role.

## OpenAI-Compatible Usage Estimates

In SDK 0.11.0 (unreleased), streaming and non-streaming responses estimate missing usage fields with the same counters.
Provider counts, including zero, take precedence; a missing or null count is estimated independently.
Completion counts the text sent from the first prompt message, while chat counts the complete message history and the tool definitions sent with the request.
Output estimates include text and tool calls, including responses with no text content.
These local estimates use the SDK tokenizer and pricing configuration; they do not represent the provider's billing record.
The public `get_num_tokens()` budget for completion also counts only the first prompt message and any supplied tool definitions.

Provider overrides of `_handle_generate_response()`, `_handle_generate_stream_response()`, and `_create_final_llm_result_chunk()` must accept and forward the optional `tools` parameter in SDK 0.11.0.
Overrides of `_num_tokens_from_string()` must follow the SDK signature `(text, tools=None)`; the method does not take a model name.

## OpenAI-Compatible Streaming

In SDK 0.11.0 (unreleased), `OAICompatLargeLanguageModel` preserves SSE data alongside comments and unknown fields.
Expand Down
128 changes: 75 additions & 53 deletions src/dify_plugin/interfaces/model/openai_compatible/llm.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
LLMResult,
LLMResultChunk,
LLMResultChunkDelta,
LLMUsage,
)
from dify_plugin.entities.model.message import (
AssistantPromptMessage,
Expand Down Expand Up @@ -301,6 +302,10 @@ def get_num_tokens(
The return value.
"""
del model
if credentials.get("mode") == LLMMode.COMPLETION.value:
return self._num_tokens_from_string(
prompt_messages[0].get_text_content(), tools
)
return self._num_tokens_from_messages(prompt_messages, tools, credentials)

def _request_credentials_validation(
Expand Down Expand Up @@ -650,7 +655,20 @@ def _generate(
]
elif completion_type is LLMMode.COMPLETION:
endpoint_url = self._join_endpoint_url(endpoint_url, "completions")
data["prompt"] = prompt_messages[0].content
prompt = prompt_messages[0]
if isinstance(prompt.content, list):
if any(
part.type != PromptMessageContentType.TEXT
for part in prompt.content
):
msg = (
"Completion mode only supports text input. "
"Use a compatible chat model for multimodal input."
)
raise ValueError(msg)
data["prompt"] = prompt.get_text_content()
else:
data["prompt"] = prompt.content
else:
msg = "Unsupported completion type for model configuration."
raise ValueError(msg)
Expand All @@ -669,7 +687,7 @@ def _generate(
for tool in tools
]
elif function_calling_type == "tool_call":
data["tool_choice"] = "auto"
data.setdefault("tool_choice", "auto")

formatted_tools.extend(
PromptMessageFunction(function=tool).model_dump() for tool in tools
Expand All @@ -683,6 +701,9 @@ def _generate(
if user:
data["user"] = user

if function_calling_type not in {"tool_call", "function_call"}:
tools = None

response = requests.post(
endpoint_url,
headers=headers,
Expand Down Expand Up @@ -713,6 +734,7 @@ def _generate(
credentials,
response,
prompt_messages,
tools=tools,
)

with response:
Expand All @@ -721,54 +743,72 @@ def _generate(
credentials,
response,
prompt_messages,
tools=tools,
)

def _create_final_llm_result_chunk(
def _get_response_usage(
self,
index: int,
message: AssistantPromptMessage,
finish_reason: str | None,
usage: dict | None,
model: str,
prompt_messages: list[PromptMessage],
credentials: dict,
full_content: str,
) -> LLMResultChunk:
# calculate num tokens
prompt_messages: list[PromptMessage],
message: AssistantPromptMessage,
usage: dict | None,
tools: list[PromptMessageTool] | None = None,
) -> LLMUsage:
"""Estimate only the token counts omitted by the provider."""
prompt_tokens = (usage or {}).get("prompt_tokens")
if prompt_tokens is None:
if credentials.get("mode") == LLMMode.COMPLETION.value:
prompt_tokens = self._num_tokens_from_string(
prompt_messages[0].content or "",
prompt_messages[0].get_text_content(), tools
)
else:
prompt_tokens = self._num_tokens_from_messages(
prompt_messages,
credentials=credentials,
prompt_messages, tools, credentials
)
completion_tokens = (usage or {}).get("completion_tokens")
if completion_tokens is None:
completion_tokens = self._num_tokens_from_string(text=full_content)
completion_tokens = self._num_tokens_from_string(message.get_text_content())
completion_tokens += sum(
self._num_tokens_for_tool_call(tool_call.model_dump())
self._num_tokens_for_function_call(tool_call.function.model_dump())
if credentials.get("function_calling_type") == "function_call"
else self._num_tokens_for_tool_call(tool_call.model_dump())
for tool_call in message.tool_calls
)

# transform usage
usage = self._calc_response_usage(
return self._calc_response_usage(
model,
credentials,
prompt_tokens,
completion_tokens,
)

def _create_final_llm_result_chunk(
self,
index: int,
message: AssistantPromptMessage,
finish_reason: str | None,
usage: dict | None,
model: str,
prompt_messages: list[PromptMessage],
credentials: dict,
full_content: str,
tools: list[PromptMessageTool] | None = None,
) -> LLMResultChunk:
return LLMResultChunk(
model=model,
delta=LLMResultChunkDelta(
index=index,
message=message,
finish_reason=finish_reason,
usage=usage,
usage=self._get_response_usage(
model,
credentials,
prompt_messages,
message.model_copy(update={"content": full_content}),
usage,
tools,
),
),
)

Expand All @@ -778,6 +818,7 @@ def _handle_generate_stream_response(
credentials: dict,
response: requests.Response,
prompt_messages: list[PromptMessage],
tools: list[PromptMessageTool] | None = None,
) -> Generator:
"""Handle llm stream response

Expand Down Expand Up @@ -943,6 +984,7 @@ def _handle_generate_stream_response(
credentials=credentials,
prompt_messages=prompt_messages,
full_content=full_assistant_content,
tools=tools,
)

def _handle_generate_response(
Expand All @@ -951,6 +993,7 @@ def _handle_generate_response(
credentials: dict,
response: requests.Response,
prompt_messages: list[PromptMessage],
tools: list[PromptMessageTool] | None = None,
) -> LLMResult:
response_json: dict = response.json()

Expand All @@ -963,11 +1006,15 @@ def _handle_generate_response(
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"]
response_message = output.get("message", {})
response_content = response_message.get("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")
if "content" not in response_message and not tool_calls:
msg = "Model response has neither content nor tool calls"
raise InvokeError(msg)

elif completion_type is LLMMode.COMPLETION:
response_content = output["text"]
Expand All @@ -987,44 +1034,19 @@ def _handle_generate_response(
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
if prompt_messages[0].content is None:
msg = "Prompt message content is required"
raise ValueError(msg)
prompt_content = cast("str", prompt_messages[0].content)
prompt_tokens = self._num_tokens_from_string(
model,
prompt_content,
)
if assistant_message.content is None:
msg = "Assistant message content is required"
raise ValueError(msg)
assistant_content = cast("str", assistant_message.content)
completion_tokens = self._num_tokens_from_string(
model,
assistant_content,
)

# transform usage
usage = self._calc_response_usage(
model,
credentials,
prompt_tokens,
completion_tokens,
)

# transform response
return LLMResult(
id=message_id,
model=response_json.get("model", model),
message=assistant_message,
usage=usage,
usage=self._get_response_usage(
model,
credentials,
prompt_messages,
assistant_message,
response_json.get("usage"),
tools,
),
)

def _convert_prompt_message_to_dict(
Expand Down
Loading
Loading