from typing import Optional, Tuple, Union, Dict, List from openai import OpenAI, AsyncOpenAI from pydantic import BaseModel, SecretStr from deepeval.errors import DeepEvalError from deepeval.config.settings import get_settings from deepeval.models.retry_policy import ( create_retry_decorator, sdk_retries_for, ) from deepeval.models.llms.utils import trim_and_load_json from deepeval.models.utils import ( require_costs, require_secret_api_key, EvaluationCost, ) from deepeval.test_case import MLLMImage from deepeval.utils import check_if_multimodal, convert_to_multi_modal_array from deepeval.models import DeepEvalBaseLLM from deepeval.constants import ProviderSlug as PS from deepeval.models.llms.constants import KIMI_MODELS_DATA from deepeval.utils import require_param retry_kimi = create_retry_decorator(PS.KIMI) class KimiModel(DeepEvalBaseLLM): def __init__( self, model: Optional[str] = None, api_key: Optional[str] = None, temperature: Optional[float] = None, cost_per_input_token: Optional[float] = None, cost_per_output_token: Optional[float] = None, generation_kwargs: Optional[Dict] = None, **kwargs, ): settings = get_settings() model = model or settings.MOONSHOT_MODEL_NAME if temperature is not None: temperature = float(temperature) elif settings.TEMPERATURE is not None: temperature = settings.TEMPERATURE else: temperature = 0.0 cost_per_input_token = ( cost_per_input_token if cost_per_input_token is not None else settings.MOONSHOT_COST_PER_INPUT_TOKEN ) cost_per_output_token = ( cost_per_output_token if cost_per_output_token is not None else settings.MOONSHOT_COST_PER_OUTPUT_TOKEN ) if api_key is not None: # keep it secret, keep it safe from serializings, logging and alike self.api_key: Optional[SecretStr] = SecretStr(api_key) else: self.api_key = settings.MOONSHOT_API_KEY # validation model = require_param( model, provider_label="KimiModel", env_var_name="MOONSHOT_MODEL_NAME", param_hint="model", ) if temperature < 0: raise DeepEvalError("Temperature must be >= 0.") self.model_data = KIMI_MODELS_DATA.get(model) self.temperature = temperature cost_per_input_token, cost_per_output_token = require_costs( self.model_data, model, "MOONSHOT_COST_PER_INPUT_TOKEN", "MOONSHOT_COST_PER_OUTPUT_TOKEN", cost_per_input_token, cost_per_output_token, ) self.model_data.input_price = float(cost_per_input_token) self.model_data.output_price = float(cost_per_output_token) self.base_url = "https://api.moonshot.cn/v1" # Keep sanitized kwargs for client call to strip legacy keys self.kwargs = kwargs self.kwargs.pop("temperature", None) self.generation_kwargs = dict(generation_kwargs or {}) self.generation_kwargs.pop("temperature", None) super().__init__(model) ############################################### # Other generate functions ############################################### @retry_kimi def generate( self, prompt: str, schema: Optional[BaseModel] = None ) -> Tuple[Union[str, BaseModel], float]: if check_if_multimodal(prompt): prompt = convert_to_multi_modal_array(input=prompt) content = self.generate_content(prompt) else: content = [{"type": "text", "text": prompt}] client = self.load_model(async_mode=False) if schema and self.supports_json_mode() is True: completion = client.chat.completions.create( model=self.name, messages=[{"role": "user", "content": content}], response_format={"type": "json_object"}, temperature=self.temperature, **self.generation_kwargs, ) json_output = trim_and_load_json( completion.choices[0].message.content ) cost = self.calculate_cost( completion.usage.prompt_tokens, completion.usage.completion_tokens, ) return schema.model_validate(json_output), cost completion = client.chat.completions.create( model=self.name, messages=[{"role": "user", "content": content}], **self.generation_kwargs, ) output = completion.choices[0].message.content cost = self.calculate_cost( completion.usage.prompt_tokens, completion.usage.completion_tokens, ) if schema: json_output = trim_and_load_json(output) return schema.model_validate(json_output), cost else: return output, cost @retry_kimi async def a_generate( self, prompt: str, schema: Optional[BaseModel] = None ) -> Tuple[Union[str, BaseModel], float]: if check_if_multimodal(prompt): prompt = convert_to_multi_modal_array(input=prompt) content = self.generate_content(prompt) else: content = [{"type": "text", "text": prompt}] client = self.load_model(async_mode=True) if schema and self.supports_json_mode() is True: completion = await client.chat.completions.create( model=self.name, messages=[{"role": "user", "content": content}], response_format={"type": "json_object"}, temperature=self.temperature, **self.generation_kwargs, ) json_output = trim_and_load_json( completion.choices[0].message.content ) cost = self.calculate_cost( completion.usage.prompt_tokens, completion.usage.completion_tokens, ) return schema.model_validate(json_output), cost completion = await client.chat.completions.create( model=self.name, messages=[{"role": "user", "content": content}], **self.generation_kwargs, ) output = completion.choices[0].message.content cost = self.calculate_cost( completion.usage.prompt_tokens, completion.usage.completion_tokens, ) if schema: json_output = trim_and_load_json(output) return schema.model_validate(json_output), cost else: return output, cost def generate_content( self, multimodal_input: List[Union[str, MLLMImage]] = [] ): content = [] for element in multimodal_input: if isinstance(element, str): content.append({"type": "text", "text": element}) elif isinstance(element, MLLMImage): if element.url and not element.local: content.append( { "type": "image_url", "image_url": {"url": element.url}, } ) else: element.ensure_images_loaded() data_uri = ( f"data:{element.mimeType};base64,{element.dataBase64}" ) content.append( { "type": "image_url", "image_url": {"url": data_uri}, } ) return content ############################################### # Utilities ############################################### def calculate_cost(self, input_tokens: int, output_tokens: int) -> float: if self.model_data.input_price and self.model_data.output_price: input_cost = input_tokens * self.model_data.input_price output_cost = output_tokens * self.model_data.output_price return EvaluationCost( input_cost + output_cost, input_tokens, output_tokens ) ############################################### # Capabilities ############################################### def supports_log_probs(self) -> Union[bool, None]: return self.model_data.supports_log_probs def supports_temperature(self) -> Union[bool, None]: return self.model_data.supports_temperature def supports_multimodal(self) -> Union[bool, None]: return self.model_data.supports_multimodal def supports_structured_outputs(self) -> Union[bool, None]: return self.model_data.supports_structured_outputs def supports_json_mode(self) -> Union[bool, None]: return self.model_data.supports_json ############################################### # Model ############################################### def load_model(self, async_mode: bool = False): if not async_mode: return self._build_client(OpenAI) return self._build_client(AsyncOpenAI) def _client_kwargs(self) -> Dict: """ If Tenacity is managing retries, force OpenAI SDK retries off to avoid double retries. If the user opts into SDK retries for 'kimi' via DEEPEVAL_SDK_RETRY_PROVIDERS, leave their retry settings as is. """ kwargs = dict(self.kwargs or {}) if not sdk_retries_for(PS.KIMI): kwargs["max_retries"] = 0 return kwargs def _build_client(self, cls): api_key = require_secret_api_key( self.api_key, provider_label="Kimi", env_var_name="MOONSHOT_API_KEY", param_hint="`api_key` to KimiModel(...)", ) kw = dict( api_key=api_key, base_url=self.base_url, **self._client_kwargs(), ) try: return cls(**kw) except TypeError as e: # older OpenAI SDKs may not accept max_retries, in that case remove and retry once if "max_retries" in str(e): kw.pop("max_retries", None) return cls(**kw) raise def get_model_name(self): return f"{self.name} (KIMI)"