Files
dify/api/core/model_manager.py

1228 lines
48 KiB
Python

import logging
from collections.abc import Callable, Generator, Iterable, Mapping, Sequence
from copy import deepcopy
from typing import IO, TYPE_CHECKING, Any, Literal, Optional, ParamSpec, TypeVar, cast, overload, override
from uuid import UUID
from configs import dify_config
from core.credit_usage import (
CreditUsageAppType,
CreditUsageAppTypeInput,
CreditUsageCreatedBy,
CreditUsageCreatedByInput,
normalize_credit_usage_app_type,
normalize_credit_usage_created_by,
)
from core.entities import PluginCredentialType
from core.entities.embedding_type import EmbeddingInputType
from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle
from core.entities.provider_entities import ModelLoadBalancingConfiguration
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError
from core.model_billing_profile import ModelBillingProfileService
from core.model_context import get_credit_usage_metadata
from core.plugin.impl.model_runtime_factory import create_plugin_provider_manager
from core.provider_manager import ProviderManager
from extensions.ext_redis import redis_client
from graphon.model_runtime.callbacks.base_callback import Callback
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import PromptMessage, PromptMessageTool
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelFeature, ModelType
from graphon.model_runtime.entities.rerank_entities import MultimodalRerankInput, RerankResult
from graphon.model_runtime.entities.text_embedding_entities import EmbeddingResult
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeConnectionError, InvokeRateLimitError
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
from graphon.model_runtime.model_providers.base.moderation_model import ModerationModel
from graphon.model_runtime.model_providers.base.rerank_model import RerankModel
from graphon.model_runtime.model_providers.base.speech2text_model import Speech2TextModel
from graphon.model_runtime.model_providers.base.text_embedding_model import TextEmbeddingModel
from graphon.model_runtime.model_providers.base.tts_model import TTSModel
from models.provider import ProviderType
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from core.app.llm.message_billing import ClassicMessageBilling
P = ParamSpec("P")
R = TypeVar("R")
class ModelInstance:
"""
Model instance class.
"""
def __init__(
self,
provider_model_bundle: ProviderModelBundle,
model: str,
credentials: dict | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> None:
self.provider_model_bundle = provider_model_bundle
self.model_name = model
self.provider = provider_model_bundle.configuration.provider.provider
if credentials is None:
credentials = self._fetch_credentials_from_bundle(provider_model_bundle, model)
self.credentials = credentials
self._message_billing: ClassicMessageBilling | None = None
self._request_metadata = dict(request_metadata) if request_metadata else None
# Runtime LLM invocation fields.
self.parameters: Mapping[str, Any] = {}
self.stop: Sequence[str] = ()
self.model_type_instance = self.provider_model_bundle.model_type_instance
self.load_balancing_manager = self._get_load_balancing_manager(
configuration=provider_model_bundle.configuration,
model_type=provider_model_bundle.model_type_instance.model_type,
model=model,
credentials=self.credentials,
)
def _resolve_request_metadata(self, request_metadata: Mapping[str, object] | None) -> Mapping[str, object] | None:
bound_request_metadata = self._request_metadata
if bound_request_metadata is None:
if request_metadata is None:
return get_credit_usage_metadata()
return request_metadata
if request_metadata is None:
return bound_request_metadata
return {**bound_request_metadata, **request_metadata}
def get_model_schema(self) -> AIModelEntity:
"""Return the resolved schema for the current model instance."""
model_schema = self.model_type_instance.get_model_schema(self.model_name, self.credentials)
if model_schema is None:
raise ValueError(f"model schema not found for {self.model_name}")
return model_schema
@staticmethod
def _fetch_credentials_from_bundle(provider_model_bundle: ProviderModelBundle, model: str):
"""
Fetch credentials from provider model bundle
:param provider_model_bundle: provider model bundle
:param model: model name
:return:
"""
configuration = provider_model_bundle.configuration
model_type = provider_model_bundle.model_type_instance.model_type
credentials = configuration.get_current_credentials(model_type=model_type, model=model)
if credentials is None:
raise ProviderTokenNotInitError(f"Model {model} credentials is not initialized.")
return credentials
@staticmethod
def _get_load_balancing_manager(
configuration: ProviderConfiguration, model_type: ModelType, model: str, credentials: dict[str, Any]
) -> Optional["LBModelManager"]:
"""
Get load balancing model credentials
:param configuration: provider configuration
:param model_type: model type
:param model: model name
:param credentials: model credentials
:return:
"""
if configuration.model_settings and configuration.using_provider_type == ProviderType.CUSTOM:
current_model_setting = None
# check if model is disabled by admin
for model_setting in configuration.model_settings:
if model_setting.model_type == model_type and model_setting.model == model:
current_model_setting = model_setting
break
# check if load balancing is enabled
if current_model_setting and current_model_setting.load_balancing_configs:
# use load balancing proxy to choose credentials
lb_model_manager = LBModelManager(
tenant_id=configuration.tenant_id,
provider=configuration.provider.provider,
model_type=model_type,
model=model,
load_balancing_configs=current_model_setting.load_balancing_configs,
managed_credentials=credentials if configuration.custom_configuration.provider else None,
)
return lb_model_manager
return None
@overload
def invoke_llm(
self,
prompt_messages: Sequence[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: Literal[True] = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Generator: ...
@overload
def invoke_llm(
self,
prompt_messages: list[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: Literal[False] = False,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult: ...
@overload
def invoke_llm(
self,
prompt_messages: list[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult | Generator: ...
def invoke_llm(
self,
prompt_messages: Sequence[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: Sequence[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult | Generator:
"""
Invoke large language model
: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 callbacks: callbacks
:param request_metadata: optional request metadata
:return: full response or stream response chunk generator result
"""
if not isinstance(self.model_type_instance, LargeLanguageModel):
raise Exception("Model type instance is not LargeLanguageModel")
request_metadata = self._resolve_request_metadata(request_metadata)
return cast(
LLMResult | Generator,
self._round_robin_invoke(
self.model_type_instance.invoke,
model=self.model_name,
credentials=self.credentials,
prompt_messages=list(prompt_messages),
model_parameters=model_parameters,
tools=list(tools) if tools else None,
stop=list(stop) if stop else None,
stream=stream,
callbacks=callbacks,
request_metadata=request_metadata,
),
)
def get_llm_num_tokens(
self, prompt_messages: Sequence[PromptMessage], tools: Sequence[PromptMessageTool] | None = None
) -> int:
"""
Get number of tokens for llm
:param prompt_messages: prompt messages
:param tools: tools for tool calling
:return:
"""
if not isinstance(self.model_type_instance, LargeLanguageModel):
raise Exception("Model type instance is not LargeLanguageModel")
return self._round_robin_invoke(
self.model_type_instance.get_num_tokens,
model=self.model_name,
credentials=self.credentials,
prompt_messages=list(prompt_messages),
tools=list(tools) if tools else None,
)
def invoke_text_embedding(
self,
texts: list[str],
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT,
*,
request_metadata: Mapping[str, object] | None = None,
) -> EmbeddingResult:
"""
Invoke large language model
:param texts: texts to embed
:param input_type: input type
:return: embeddings result
"""
if not isinstance(self.model_type_instance, TextEmbeddingModel):
raise Exception("Model type instance is not TextEmbeddingModel")
return self._round_robin_invoke(
self.model_type_instance.invoke,
model=self.model_name,
credentials=self.credentials,
texts=texts,
input_type=input_type,
request_metadata=self._resolve_request_metadata(request_metadata),
)
def invoke_multimodal_embedding(
self,
multimodel_documents: list[dict],
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT,
*,
request_metadata: Mapping[str, object] | None = None,
) -> EmbeddingResult:
"""
Invoke large language model
:param multimodel_documents: multimodel documents to embed
:param input_type: input type
:return: embeddings result
"""
if not isinstance(self.model_type_instance, TextEmbeddingModel):
raise Exception("Model type instance is not TextEmbeddingModel")
return self._round_robin_invoke(
self.model_type_instance.invoke,
model=self.model_name,
credentials=self.credentials,
multimodel_documents=multimodel_documents,
input_type=input_type,
request_metadata=self._resolve_request_metadata(request_metadata),
)
def get_text_embedding_num_tokens(self, texts: list[str]) -> list[int]:
"""
Get number of tokens for text embedding
:param texts: texts to embed
:return:
"""
if not isinstance(self.model_type_instance, TextEmbeddingModel):
raise Exception("Model type instance is not TextEmbeddingModel")
return self._round_robin_invoke(
self.model_type_instance.get_num_tokens,
model=self.model_name,
credentials=self.credentials,
texts=texts,
)
def invoke_rerank(
self,
query: str,
docs: list[str],
score_threshold: float | None = None,
top_n: int | None = None,
*,
request_metadata: Mapping[str, object] | None = None,
) -> RerankResult:
"""
Invoke rerank model
:param query: search query
:param docs: docs for reranking
:param score_threshold: score threshold
:param top_n: top n
:return: rerank result
"""
if not isinstance(self.model_type_instance, RerankModel):
raise Exception("Model type instance is not RerankModel")
return self._round_robin_invoke(
self.model_type_instance.invoke,
model=self.model_name,
credentials=self.credentials,
query=query,
docs=docs,
score_threshold=score_threshold,
top_n=top_n,
request_metadata=self._resolve_request_metadata(request_metadata),
)
def invoke_multimodal_rerank(
self,
query: MultimodalRerankInput,
docs: list[MultimodalRerankInput],
score_threshold: float | None = None,
top_n: int | None = None,
*,
request_metadata: Mapping[str, object] | None = None,
) -> RerankResult:
"""
Invoke rerank model
:param query: search query
:param docs: docs for reranking
:param score_threshold: score threshold
:param top_n: top n
:return: rerank result
"""
if not isinstance(self.model_type_instance, RerankModel):
raise Exception("Model type instance is not RerankModel")
return self._round_robin_invoke(
self.model_type_instance.invoke_multimodal_rerank,
model=self.model_name,
credentials=self.credentials,
query=query,
docs=docs,
score_threshold=score_threshold,
top_n=top_n,
request_metadata=self._resolve_request_metadata(request_metadata),
)
def invoke_moderation(self, text: str, *, request_metadata: Mapping[str, object] | None = None) -> bool:
"""
Invoke moderation model
:param text: text to moderate
:return: false if text is safe, true otherwise
"""
if not isinstance(self.model_type_instance, ModerationModel):
raise Exception("Model type instance is not ModerationModel")
return self._round_robin_invoke(
self.model_type_instance.invoke,
model=self.model_name,
credentials=self.credentials,
text=text,
request_metadata=self._resolve_request_metadata(request_metadata),
)
def invoke_speech2text(self, file: IO[bytes], *, request_metadata: Mapping[str, object] | None = None) -> str:
"""
Invoke large language model
:param file: audio file
:return: text for given audio file
"""
if not isinstance(self.model_type_instance, Speech2TextModel):
raise Exception("Model type instance is not Speech2TextModel")
return self._round_robin_invoke(
self.model_type_instance.invoke,
model=self.model_name,
credentials=self.credentials,
file=file,
request_metadata=self._resolve_request_metadata(request_metadata),
)
def invoke_tts(
self,
content_text: str,
voice: str = "",
*,
request_metadata: Mapping[str, object] | None = None,
) -> Iterable[bytes]:
"""
Invoke a text-to-speech model.
:param content_text: text content to be translated
:param voice: model timbre
:return: audio byte stream
"""
if not isinstance(self.model_type_instance, TTSModel):
raise Exception("Model type instance is not TTSModel")
chunks = self._round_robin_invoke(
self.model_type_instance.invoke,
model=self.model_name,
credentials=self.credentials,
content_text=content_text,
voice=voice,
request_metadata=self._resolve_request_metadata(request_metadata),
)
return (chunk.data for chunk in chunks)
def _round_robin_invoke(self, function: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
"""
Round-robin invoke
:param function: function to invoke
:param args: function args
:param kwargs: function kwargs
:return:
"""
message_billing = self._message_billing
if message_billing is not None:
message_billing.check_active()
if not self.load_balancing_manager:
return function(*args, **kwargs)
last_exception: InvokeRateLimitError | InvokeAuthorizationError | InvokeConnectionError | None = None
while True:
lb_config = self.load_balancing_manager.fetch_next()
if not lb_config:
if not last_exception:
raise ProviderTokenNotInitError("Model credentials is not initialized.")
else:
raise last_exception
# Additional policy compliance check as fallback (in case fetch_next didn't catch it)
try:
from core.helper.credential_utils import runtime_check_credential_policy_compliance
if lb_config.credential_id:
runtime_check_credential_policy_compliance(
credential_id=lb_config.credential_id,
provider=self.provider,
credential_type=PluginCredentialType.MODEL,
)
except Exception as e:
logger.warning(
"Load balancing config %s failed policy compliance check in round-robin: %s", lb_config.id, str(e)
)
self.load_balancing_manager.cooldown(lb_config, expire=60)
continue
try:
kwargs["credentials"] = lb_config.credentials
return function(*args, **kwargs)
except InvokeRateLimitError as e:
# expire in 60 seconds
self.load_balancing_manager.cooldown(lb_config, expire=60)
last_exception = e
continue
except (InvokeAuthorizationError, InvokeConnectionError) as e:
# expire in 10 seconds
self.load_balancing_manager.cooldown(lb_config, expire=10)
last_exception = e
continue
except Exception as e:
raise e
def get_tts_voices(self, language: str | None = None):
"""
Invoke large language tts model voices
:param language: tts language
:return: tts model voices
"""
if not isinstance(self.model_type_instance, TTSModel):
raise Exception("Model type instance is not TTSModel")
return self.model_type_instance.get_tts_model_voices(
model=self.model_name, credentials=self.credentials, language=language
)
class QuotaManagedModelInstance(ModelInstance):
"""A system-hosted model instance that owns quota settlement per invocation."""
def reserve_quota(
self,
*,
request_id: str | None = None,
app_type: CreditUsageAppTypeInput = None,
created_by: CreditUsageCreatedByInput = None,
):
from core.app.llm.quota import reserve_model_quota_for_model
from core.model_invocation_routing import validate_invocation_admission
validate_invocation_admission(
self.credentials,
tenant_id=self.provider_model_bundle.configuration.tenant_id,
provider=self.provider,
model_type=self.model_type_instance.model_type,
model=self.model_name,
)
if app_type is None:
app_type = self._get_reservation_app_type(self._request_metadata)
if created_by is None:
created_by = self._get_reservation_created_by(self._request_metadata)
return reserve_model_quota_for_model(
tenant_id=self.provider_model_bundle.configuration.tenant_id,
provider=self.provider,
model_type=self.model_type_instance.model_type,
model=self.model_name,
request_id=request_id,
app_type=app_type,
created_by=created_by,
provider_configuration=self.provider_model_bundle.configuration,
invocation_credentials=self.credentials,
)
@staticmethod
def _get_reservation_request_id(request_metadata: Mapping[str, object] | None) -> str | None:
request_id = request_metadata.get("invocation_id") if request_metadata else None
if not isinstance(request_id, str) or not request_id:
return None
try:
return str(UUID(request_id))
except ValueError:
return None
@staticmethod
def _get_reservation_created_by(
request_metadata: Mapping[str, object] | None,
) -> CreditUsageCreatedBy | None:
created_by = request_metadata.get("created_by") if request_metadata else None
if created_by is None:
return None
return normalize_credit_usage_created_by(created_by)
@staticmethod
def _get_reservation_app_type(
request_metadata: Mapping[str, object] | None,
) -> CreditUsageAppType | None:
app_type = request_metadata.get("app_type") if request_metadata else None
if app_type is None:
return None
return normalize_credit_usage_app_type(app_type)
def _reserve_quota_for_request(self, request_metadata: Mapping[str, object] | None):
# Validate the prepared binding, never mutate shared instance credentials
# between schema/token reads and this invocation's reservation.
request_id = self._get_reservation_request_id(request_metadata)
app_type = self._get_reservation_app_type(request_metadata)
created_by = self._get_reservation_created_by(request_metadata)
if request_id is None and app_type is None and created_by is None:
return self.reserve_quota()
reservation_kwargs: dict[str, str | CreditUsageAppType | CreditUsageCreatedBy] = {}
if request_id is not None:
reservation_kwargs["request_id"] = request_id
if app_type is not None:
reservation_kwargs["app_type"] = app_type
if created_by is not None:
reservation_kwargs["created_by"] = created_by
return self.reserve_quota(**reservation_kwargs)
@staticmethod
def release_quota_safely(reservation) -> None:
try:
reservation.release()
except Exception:
logger.exception("Failed to release model quota reservation")
def _invoke_with_quota(self, function: Callable[P, R], *args: P.args, **kwargs: P.kwargs) -> R:
request_metadata = kwargs.get("request_metadata")
effective_request_metadata = self._resolve_request_metadata(
request_metadata if isinstance(request_metadata, Mapping) else None
)
if effective_request_metadata is not None:
kwargs["request_metadata"] = effective_request_metadata
reservation = self._reserve_quota_for_request(effective_request_metadata)
try:
response = function(*args, **kwargs)
reservation.commit()
return response
finally:
self.release_quota_safely(reservation)
@overload
def invoke_llm(
self,
prompt_messages: Sequence[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: Literal[True] = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> Generator: ...
@overload
def invoke_llm(
self,
prompt_messages: list[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: Literal[False] = False,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult: ...
@overload
def invoke_llm(
self,
prompt_messages: list[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: list[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult | Generator: ...
@override
def invoke_llm(
self,
prompt_messages: Sequence[PromptMessage],
model_parameters: dict[str, Any] | None = None,
tools: Sequence[PromptMessageTool] | None = None,
stop: Sequence[str] | None = None,
stream: bool = True,
callbacks: list[Callback] | None = None,
request_metadata: Mapping[str, object] | None = None,
) -> LLMResult | Generator:
normalized_prompt_messages = list(prompt_messages)
normalized_stop = list(stop) if stop else None
if stream:
return self._invoke_llm_stream(
prompt_messages=normalized_prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=normalized_stop,
callbacks=callbacks,
request_metadata=request_metadata,
)
effective_request_metadata = self._resolve_request_metadata(request_metadata)
reservation = self._reserve_quota_for_request(effective_request_metadata)
try:
response = super().invoke_llm(
prompt_messages=normalized_prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=normalized_stop,
stream=False,
callbacks=callbacks,
request_metadata=effective_request_metadata,
)
if isinstance(response, Generator):
raise TypeError("Non-streaming LLM invocation returned a generator.")
reservation.commit(response.usage)
return response
finally:
self.release_quota_safely(reservation)
def _invoke_llm_stream(
self,
*,
prompt_messages: list[PromptMessage],
model_parameters: dict[str, Any] | None,
tools: Sequence[PromptMessageTool] | None,
stop: list[str] | None,
callbacks: list[Callback] | None,
request_metadata: Mapping[str, object] | None,
) -> Generator:
effective_request_metadata = self._resolve_request_metadata(request_metadata)
reservation = self._reserve_quota_for_request(effective_request_metadata)
usage: LLMUsage | None = None
try:
response = super().invoke_llm(
prompt_messages=prompt_messages,
model_parameters=model_parameters,
tools=tools,
stop=stop,
stream=True,
callbacks=callbacks,
request_metadata=effective_request_metadata,
)
if not isinstance(response, Generator):
raise TypeError("Streaming LLM invocation did not return a generator.")
if reservation.commit_before_delivery:
for chunk in response:
chunk_usage = chunk.delta.usage
if chunk_usage is not None:
usage = chunk_usage
reservation.commit(usage)
yield chunk
return
buffered_chunks = []
for chunk in response:
chunk_usage = chunk.delta.usage
if chunk_usage is not None:
usage = chunk_usage
buffered_chunks.append(chunk)
reservation.commit(usage)
yield from buffered_chunks
finally:
self.release_quota_safely(reservation)
@override
def invoke_text_embedding(
self,
texts: list[str],
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT,
*,
request_metadata: Mapping[str, object] | None = None,
) -> EmbeddingResult:
return self._invoke_with_quota(
super().invoke_text_embedding,
texts=texts,
input_type=input_type,
request_metadata=request_metadata,
)
@override
def invoke_multimodal_embedding(
self,
multimodel_documents: list[dict],
input_type: EmbeddingInputType = EmbeddingInputType.DOCUMENT,
*,
request_metadata: Mapping[str, object] | None = None,
) -> EmbeddingResult:
return self._invoke_with_quota(
super().invoke_multimodal_embedding,
multimodel_documents=multimodel_documents,
input_type=input_type,
request_metadata=request_metadata,
)
@override
def invoke_rerank(
self,
query: str,
docs: list[str],
score_threshold: float | None = None,
top_n: int | None = None,
*,
request_metadata: Mapping[str, object] | None = None,
) -> RerankResult:
return self._invoke_with_quota(
super().invoke_rerank,
query=query,
docs=docs,
score_threshold=score_threshold,
top_n=top_n,
request_metadata=request_metadata,
)
@override
def invoke_multimodal_rerank(
self,
query: MultimodalRerankInput,
docs: list[MultimodalRerankInput],
score_threshold: float | None = None,
top_n: int | None = None,
*,
request_metadata: Mapping[str, object] | None = None,
) -> RerankResult:
return self._invoke_with_quota(
super().invoke_multimodal_rerank,
query=query,
docs=docs,
score_threshold=score_threshold,
top_n=top_n,
request_metadata=request_metadata,
)
@override
def invoke_moderation(self, text: str, *, request_metadata: Mapping[str, object] | None = None) -> bool:
return self._invoke_with_quota(super().invoke_moderation, text=text, request_metadata=request_metadata)
@override
def invoke_speech2text(self, file: IO[bytes], *, request_metadata: Mapping[str, object] | None = None) -> str:
return self._invoke_with_quota(super().invoke_speech2text, file=file, request_metadata=request_metadata)
@override
def invoke_tts(
self,
content_text: str,
voice: str = "",
*,
request_metadata: Mapping[str, object] | None = None,
) -> Iterable[bytes]:
return self._invoke_tts_stream(content_text=content_text, voice=voice, request_metadata=request_metadata)
def _invoke_tts_stream(
self,
*,
content_text: str,
voice: str,
request_metadata: Mapping[str, object] | None = None,
) -> Generator[bytes]:
effective_request_metadata = self._resolve_request_metadata(request_metadata)
reservation = self._reserve_quota_for_request(effective_request_metadata)
try:
response = super().invoke_tts(
content_text=content_text,
voice=voice,
request_metadata=effective_request_metadata,
)
for chunk in response:
reservation.commit()
yield chunk
finally:
self.release_quota_safely(reservation)
def create_model_instance(
provider_model_bundle: ProviderModelBundle,
model: str,
credentials: dict[str, Any] | None = None,
request_metadata: Mapping[str, object] | None = None,
*,
message_billing: "ClassicMessageBilling | None" = None,
) -> ModelInstance:
"""One factory for production callers, including classic Chat/Agent apps."""
if message_billing is not None:
message_billing.check_identity(
provider_model_bundle.configuration.tenant_id, provider_model_bundle.configuration.provider.provider, model
)
# A classic Agent can invoke this model many times, but its legacy
# accounting unit remains the enclosing message's one durable receipt.
instance = ModelInstance(provider_model_bundle, model, credentials, request_metadata)
instance._message_billing = message_billing
return instance
instance_type = (
QuotaManagedModelInstance
if provider_model_bundle.configuration.using_provider_type == ProviderType.SYSTEM
else ModelInstance
)
return instance_type(provider_model_bundle, model, credentials, request_metadata)
class ModelManager:
"""Resolves :class:`ModelInstance` objects for a tenant and provider.
When ``enable_credentials_cache`` is ``True``, resolved credentials for each
``(tenant_id, provider, model_type, model)`` are stored in
``_credentials_cache`` and reused. That can return **stale** credentials after
API keys or provider settings change, so a manager constructed with
``enable_credentials_cache=True`` should not be kept for the lifetime of a
process or shared across unrelated work. Prefer a new manager per request,
workflow run, or similar bounded scope.
The default is ``enable_credentials_cache=False``; in that mode the internal
credential cache is not populated, and each ``get_model_instance`` call
loads credentials from the current provider configuration.
"""
def __init__(
self,
provider_manager: ProviderManager,
*,
enable_credentials_cache: bool = False,
request_metadata: Mapping[str, object] | None = None,
) -> None:
self._provider_manager = provider_manager
self._credentials_cache: dict[tuple[str, str, str, str], Any] = {}
self._enable_credentials_cache = enable_credentials_cache
self._request_metadata = dict(request_metadata) if request_metadata else None
@classmethod
def for_tenant(
cls,
tenant_id: str,
user_id: str | None = None,
*,
request_metadata: Mapping[str, object] | None = None,
) -> "ModelManager":
if request_metadata is None:
request_metadata = get_credit_usage_metadata()
return cls(
provider_manager=create_plugin_provider_manager(tenant_id=tenant_id, user_id=user_id),
request_metadata=request_metadata,
)
def _resolve_request_metadata(self, request_metadata: Mapping[str, object] | None) -> Mapping[str, object] | None:
if self._request_metadata is None:
return request_metadata
if request_metadata is None:
return self._request_metadata
return {**self._request_metadata, **request_metadata}
@staticmethod
def _validate_system_model_access(
provider_model_bundle: ProviderModelBundle,
*,
model_type: ModelType,
model: str,
) -> None:
configuration = provider_model_bundle.configuration
if configuration.using_provider_type != ProviderType.SYSTEM:
return
from core.model_invocation_routing import has_compatibility_route, migration_routing_state
if ModelBillingProfileService.resolve(configuration.tenant_id).uses_tokener and not has_compatibility_route(
migration_routing_state(configuration.tenant_id)
):
raise ModelCurrentlyNotSupportError(
f"Hosted SYSTEM model {model_type.value}/{model} is disabled for this workspace."
)
# Hosted allowlists retain the existing comma-separated format. Model names
# are matched exactly; model-type-specific entries will be introduced later.
quota_configuration = next(
(
quota
for quota in configuration.system_configuration.quota_configurations
if quota.quota_type == configuration.system_configuration.current_quota_type
),
None,
)
if quota_configuration is None or not quota_configuration.restrict_models:
return
if any(restricted_model.model == model for restricted_model in quota_configuration.restrict_models):
return
raise ModelCurrentlyNotSupportError(f"System model {model_type.value}/{model} is not allowed.")
@staticmethod
def _model_instance_class(provider_model_bundle: ProviderModelBundle, model_type: ModelType) -> type[ModelInstance]:
if provider_model_bundle.configuration.using_provider_type == ProviderType.SYSTEM:
return QuotaManagedModelInstance
return ModelInstance
def get_model_instance(
self,
tenant_id: str,
provider: str,
model_type: ModelType,
model: str,
*,
request_metadata: Mapping[str, object] | None = None,
) -> ModelInstance:
"""
Get model instance
:param tenant_id: tenant id
:param provider: provider name
:param model_type: model type
:param model: model name
:return:
"""
if not provider:
return self.get_default_model_instance(tenant_id, model_type)
provider_model_bundle = self._provider_manager.get_provider_model_bundle(
tenant_id=tenant_id, provider=provider, model_type=model_type
)
self._validate_system_model_access(provider_model_bundle, model_type=model_type, model=model)
model_instance_class = self._model_instance_class(provider_model_bundle, model_type)
effective_request_metadata = self._resolve_request_metadata(request_metadata)
cred_cache_key = (tenant_id, provider, model_type.value, model)
if cred_cache_key in self._credentials_cache:
from core.model_invocation_routing import ModelInvocationReprepare, validate_invocation_admission
try:
validate_invocation_admission(
self._credentials_cache[cred_cache_key],
tenant_id=tenant_id,
provider=provider,
model_type=model_type,
model=model,
)
except ModelInvocationReprepare:
self._credentials_cache.pop(cred_cache_key, None)
self._provider_manager.clear_configurations_cache(tenant_id)
return self.get_model_instance(
tenant_id, provider, model_type, model, request_metadata=request_metadata
)
return model_instance_class(
provider_model_bundle,
model,
deepcopy(self._credentials_cache[cred_cache_key]),
effective_request_metadata,
)
ret = model_instance_class(provider_model_bundle, model, request_metadata=effective_request_metadata)
if self._enable_credentials_cache:
self._credentials_cache[cred_cache_key] = deepcopy(ret.credentials)
return ret
def get_default_provider_model_name(self, tenant_id: str, model_type: ModelType) -> tuple[str | None, str | None]:
"""
Return first provider and the first model in the provider
:param tenant_id: tenant id
:param model_type: model type
:return: provider name, model name
"""
return self._provider_manager.get_first_provider_first_model(tenant_id, model_type)
def get_default_model_instance(self, tenant_id: str, model_type: ModelType) -> ModelInstance:
"""
Get default model instance
:param tenant_id: tenant id
:param model_type: model type
:return:
"""
default_model_entity = self._provider_manager.get_default_model(tenant_id=tenant_id, model_type=model_type)
if not default_model_entity:
raise ProviderTokenNotInitError(f"Default model not found for {model_type}")
return self.get_model_instance(
tenant_id=tenant_id,
provider=default_model_entity.provider.provider,
model_type=model_type,
model=default_model_entity.model,
)
def check_model_support_vision(self, tenant_id: str, provider: str, model: str, model_type: ModelType) -> bool:
"""
Check if model supports vision
:param tenant_id: tenant id
:param provider: provider name
:param model: model name
:return: True if model supports vision, False otherwise
"""
model_instance = self.get_model_instance(tenant_id, provider, model_type, model)
model_type_instance = model_instance.model_type_instance
match model_type:
case ModelType.LLM:
model_type_instance = cast(LargeLanguageModel, model_type_instance)
case ModelType.TEXT_EMBEDDING:
model_type_instance = cast(TextEmbeddingModel, model_type_instance)
case ModelType.RERANK:
model_type_instance = cast(RerankModel, model_type_instance)
case _:
raise ValueError(f"Model type {model_type} is not supported")
model_schema = model_type_instance.get_model_schema(model, model_instance.credentials)
if not model_schema:
return False
return bool(model_schema.features and ModelFeature.VISION in model_schema.features)
class LBModelManager:
def __init__(
self,
tenant_id: str,
provider: str,
model_type: ModelType,
model: str,
load_balancing_configs: list[ModelLoadBalancingConfiguration],
managed_credentials: dict[str, Any] | None = None,
):
"""
Load balancing model manager
:param tenant_id: tenant_id
:param provider: provider
:param model_type: model_type
:param model: model name
:param load_balancing_configs: all load balancing configurations
:param managed_credentials: credentials if load balancing configuration name is __inherit__
"""
self._tenant_id = tenant_id
self._provider = provider
self._model_type = model_type
self._model = model
self._load_balancing_configs = load_balancing_configs
for load_balancing_config in self._load_balancing_configs[:]: # Iterate over a shallow copy of the list
if load_balancing_config.name == "__inherit__":
if not managed_credentials:
# remove __inherit__ if managed credentials is not provided
self._load_balancing_configs.remove(load_balancing_config)
else:
load_balancing_config.credentials = managed_credentials
def fetch_next(self) -> ModelLoadBalancingConfiguration | None:
"""
Get next model load balancing config
Strategy: Round Robin
:return:
"""
cache_key = f"model_lb_index:{self._tenant_id}:{self._provider}:{self._model_type.value}:{self._model}"
cooldown_load_balancing_configs = []
max_index = len(self._load_balancing_configs)
while True:
current_index = redis_client.incr(cache_key)
current_index = cast(int, current_index)
if current_index >= 10000000:
current_index = 1
redis_client.set(cache_key, current_index)
redis_client.expire(cache_key, 3600)
if current_index > max_index:
current_index = current_index % max_index
real_index = current_index - 1
if real_index > max_index:
real_index = 0
config: ModelLoadBalancingConfiguration = self._load_balancing_configs[real_index]
if self.in_cooldown(config):
cooldown_load_balancing_configs.append(config)
if len(cooldown_load_balancing_configs) >= len(self._load_balancing_configs):
# all configs are in cooldown
return None
continue
# Check policy compliance for the selected configuration
try:
from core.helper.credential_utils import runtime_check_credential_policy_compliance
if config.credential_id:
runtime_check_credential_policy_compliance(
credential_id=config.credential_id,
provider=self._provider,
credential_type=PluginCredentialType.MODEL,
)
except Exception:
logger.warning("Load balancing config %s failed policy compliance check", config.id, exc_info=True)
cooldown_load_balancing_configs.append(config)
if len(cooldown_load_balancing_configs) >= len(self._load_balancing_configs):
# all configs are in cooldown or failed policy compliance
return None
continue
if dify_config.DEBUG:
logger.info(
"""Model LB
id: %s
name:%s
tenant_id: %s
provider: %s
model_type: %s
model: %s""",
config.id,
config.name,
self._tenant_id,
self._provider,
self._model_type.value,
self._model,
)
return config
def cooldown(self, config: ModelLoadBalancingConfiguration, expire: int = 60):
"""
Cooldown model load balancing config
:param config: model load balancing config
:param expire: cooldown time
:return:
"""
cooldown_cache_key = (
f"model_lb_index:cooldown:{self._tenant_id}:{self._provider}:"
f"{self._model_type.value}:{self._model}:{config.id}"
)
redis_client.setex(cooldown_cache_key, expire, "true")
def in_cooldown(self, config: ModelLoadBalancingConfiguration) -> bool:
"""
Check if model load balancing config is in cooldown
:param config: model load balancing config
:return:
"""
cooldown_cache_key = (
f"model_lb_index:cooldown:{self._tenant_id}:{self._provider}:"
f"{self._model_type.value}:{self._model}:{config.id}"
)
res: bool = redis_client.exists(cooldown_cache_key)
return res
@staticmethod
def get_config_in_cooldown_and_ttl(
tenant_id: str, provider: str, model_type: ModelType, model: str, config_id: str
) -> tuple[bool, int]:
"""
Get model load balancing config is in cooldown and ttl
:param tenant_id: workspace id
:param provider: provider name
:param model_type: model type
:param model: model name
:param config_id: model load balancing config id
:return:
"""
cooldown_cache_key = f"model_lb_index:cooldown:{tenant_id}:{provider}:{model_type.value}:{model}:{config_id}"
ttl = redis_client.ttl(cooldown_cache_key)
if ttl == -2:
return False, 0
ttl = cast(int, ttl)
return True, ttl