llm_service.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. # Copyright (C) 2025 AIDC-AI
  2. #
  3. # Licensed under the Apache License, Version 2.0 (the "License");
  4. # you may not use this file except in compliance with the License.
  5. # You may obtain a copy of the License at
  6. # http://www.apache.org/licenses/LICENSE-2.0
  7. # Unless required by applicable law or agreed to in writing, software
  8. # distributed under the License is distributed on an "AS IS" BASIS,
  9. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  10. # See the License for the specific language governing permissions and
  11. # limitations under the License.
  12. """
  13. LLM (Large Language Model) Service - Direct OpenAI SDK implementation
  14. Supports structured output via response_type parameter (Pydantic model).
  15. """
  16. import json
  17. import re
  18. from typing import Optional, Type, TypeVar, Union
  19. from openai import AsyncOpenAI
  20. from pydantic import BaseModel
  21. from loguru import logger
  22. T = TypeVar("T", bound=BaseModel)
  23. class LLMService:
  24. """
  25. LLM (Large Language Model) service
  26. Direct implementation using OpenAI SDK. No capability layer needed.
  27. Supports all OpenAI SDK compatible providers:
  28. - OpenAI (gpt-4o, gpt-4o-mini, gpt-3.5-turbo)
  29. - Alibaba Qwen (qwen-max, qwen-plus, qwen-turbo)
  30. - Anthropic Claude (claude-sonnet-4-5, claude-opus-4, claude-haiku-4)
  31. - DeepSeek (deepseek-chat)
  32. - Moonshot Kimi (moonshot-v1-8k, moonshot-v1-32k, moonshot-v1-128k)
  33. - Ollama (llama3.2, qwen2.5, mistral, codellama) - FREE & LOCAL!
  34. - Any custom provider with OpenAI-compatible API
  35. Usage:
  36. # Direct call
  37. answer = await pixelle_video.llm("Explain atomic habits")
  38. # With parameters
  39. answer = await pixelle_video.llm(
  40. prompt="Explain atomic habits in 3 sentences",
  41. temperature=0.7,
  42. max_tokens=2000
  43. )
  44. """
  45. def __init__(self, config: dict):
  46. """
  47. Initialize LLM service
  48. Args:
  49. config: Full application config dict (kept for backward compatibility)
  50. """
  51. # Note: We no longer cache config here to support hot reload
  52. # Config is read dynamically from config_manager in _get_config_value()
  53. self._client: Optional[AsyncOpenAI] = None
  54. def _get_config_value(self, key: str, default=None):
  55. """
  56. Get config value dynamically from config_manager (supports hot reload)
  57. Args:
  58. key: Config key name
  59. default: Default value if not found
  60. Returns:
  61. Config value
  62. """
  63. from pixelle_video.config import config_manager
  64. return getattr(config_manager.config.llm, key, default)
  65. def _create_client(
  66. self,
  67. api_key: Optional[str] = None,
  68. base_url: Optional[str] = None,
  69. ) -> AsyncOpenAI:
  70. """
  71. Create OpenAI client
  72. Args:
  73. api_key: API key (optional, uses config if not provided)
  74. base_url: Base URL (optional, uses config if not provided)
  75. Returns:
  76. AsyncOpenAI client instance
  77. """
  78. # Get API key (priority: parameter > config)
  79. final_api_key = (
  80. api_key
  81. or self._get_config_value("api_key")
  82. or "dummy-key" # Ollama doesn't need real key
  83. )
  84. # Get base URL (priority: parameter > config)
  85. final_base_url = (
  86. base_url
  87. or self._get_config_value("base_url")
  88. )
  89. # Create client
  90. client_kwargs = {"api_key": final_api_key}
  91. if final_base_url:
  92. client_kwargs["base_url"] = final_base_url
  93. return AsyncOpenAI(**client_kwargs)
  94. async def __call__(
  95. self,
  96. prompt: str,
  97. api_key: Optional[str] = None,
  98. base_url: Optional[str] = None,
  99. model: Optional[str] = None,
  100. temperature: float = 0.7,
  101. max_tokens: int = 2000,
  102. response_type: Optional[Type[T]] = None,
  103. **kwargs
  104. ) -> Union[str, T]:
  105. """
  106. Generate text using LLM
  107. Args:
  108. prompt: The prompt to generate from
  109. api_key: API key (optional, uses config if not provided)
  110. base_url: Base URL (optional, uses config if not provided)
  111. model: Model name (optional, uses config if not provided)
  112. temperature: Sampling temperature (0.0-2.0). Lower is more deterministic.
  113. max_tokens: Maximum tokens to generate
  114. response_type: Optional Pydantic model class for structured output.
  115. If provided, returns parsed model instance instead of string.
  116. **kwargs: Additional provider-specific parameters
  117. Returns:
  118. Generated text (str) or parsed Pydantic model instance (if response_type provided)
  119. Examples:
  120. # Basic text generation
  121. answer = await pixelle_video.llm("Explain atomic habits")
  122. # Structured output with Pydantic model
  123. class MovieReview(BaseModel):
  124. title: str
  125. rating: int
  126. summary: str
  127. review = await pixelle_video.llm(
  128. prompt="Review the movie Inception",
  129. response_type=MovieReview
  130. )
  131. print(review.title) # Structured access
  132. """
  133. # Create client (new instance each time to support parameter overrides)
  134. client = self._create_client(api_key=api_key, base_url=base_url)
  135. # Get model (priority: parameter > config)
  136. final_model = (
  137. model
  138. or self._get_config_value("model")
  139. or "gpt-3.5-turbo" # Default fallback
  140. )
  141. logger.debug(f"LLM call: model={final_model}, base_url={client.base_url}, response_type={response_type}")
  142. try:
  143. if response_type is not None:
  144. # Structured output mode - try beta.chat.completions.parse first
  145. return await self._call_with_structured_output(
  146. client=client,
  147. model=final_model,
  148. prompt=prompt,
  149. response_type=response_type,
  150. temperature=temperature,
  151. max_tokens=max_tokens,
  152. **kwargs
  153. )
  154. else:
  155. # Standard text output mode
  156. response = await client.chat.completions.create(
  157. model=final_model,
  158. messages=[{"role": "user", "content": prompt}],
  159. temperature=temperature,
  160. max_tokens=max_tokens,
  161. **kwargs
  162. )
  163. result = response.choices[0].message.content
  164. logger.debug(f"LLM response length: {len(result)} chars")
  165. return result
  166. except Exception as e:
  167. logger.error(f"LLM call error (model={final_model}, base_url={client.base_url}): {e}")
  168. raise
  169. async def _call_with_structured_output(
  170. self,
  171. client: AsyncOpenAI,
  172. model: str,
  173. prompt: str,
  174. response_type: Type[T],
  175. temperature: float,
  176. max_tokens: int,
  177. **kwargs
  178. ) -> T:
  179. """
  180. Call LLM with structured output support
  181. Uses JSON schema instruction appended to prompt for maximum compatibility
  182. across all OpenAI-compatible providers (Qwen, DeepSeek, etc.).
  183. Args:
  184. client: OpenAI client
  185. model: Model name
  186. prompt: The prompt
  187. response_type: Pydantic model class
  188. temperature: Sampling temperature
  189. max_tokens: Max tokens
  190. **kwargs: Additional parameters
  191. Returns:
  192. Parsed Pydantic model instance
  193. """
  194. # Build JSON schema instruction and append to prompt
  195. json_schema_instruction = self._get_json_schema_instruction(response_type)
  196. enhanced_prompt = f"{prompt}\n\n{json_schema_instruction}"
  197. # Call LLM with enhanced prompt
  198. response = await client.chat.completions.create(
  199. model=model,
  200. messages=[{"role": "user", "content": enhanced_prompt}],
  201. temperature=temperature,
  202. max_tokens=max_tokens,
  203. **kwargs
  204. )
  205. content = response.choices[0].message.content
  206. logger.debug(f"Structured output response length: {len(content)} chars")
  207. # Parse JSON from response content
  208. return self._parse_response_as_model(content, response_type)
  209. def _get_json_schema_instruction(self, response_type: Type[T]) -> str:
  210. """
  211. Generate JSON schema instruction for LLM fallback mode
  212. Args:
  213. response_type: Pydantic model class
  214. Returns:
  215. Formatted instruction string with JSON schema
  216. """
  217. try:
  218. # Get JSON schema from Pydantic model
  219. schema = response_type.model_json_schema()
  220. schema_str = json.dumps(schema, indent=2, ensure_ascii=False)
  221. return f"""## IMPORTANT: JSON Output Format Required
  222. You MUST respond with ONLY a valid JSON object (no markdown, no extra text).
  223. The JSON must strictly follow this schema:
  224. ```json
  225. {schema_str}
  226. ```
  227. Output ONLY the JSON object, nothing else."""
  228. except Exception as e:
  229. logger.warning(f"Failed to generate JSON schema: {e}")
  230. return """## IMPORTANT: JSON Output Format Required
  231. You MUST respond with ONLY a valid JSON object (no markdown, no extra text)."""
  232. def _parse_response_as_model(self, content: str, response_type: Type[T]) -> T:
  233. """
  234. Parse LLM response content as Pydantic model
  235. Args:
  236. content: Raw LLM response text
  237. response_type: Target Pydantic model class
  238. Returns:
  239. Parsed model instance
  240. """
  241. # Try direct JSON parsing first
  242. try:
  243. data = json.loads(content)
  244. return response_type.model_validate(data)
  245. except json.JSONDecodeError:
  246. pass
  247. # Try extracting from markdown code block
  248. json_pattern = r'```(?:json)?\s*([\s\S]+?)\s*```'
  249. match = re.search(json_pattern, content, re.DOTALL)
  250. if match:
  251. try:
  252. data = json.loads(match.group(1))
  253. return response_type.model_validate(data)
  254. except json.JSONDecodeError:
  255. pass
  256. # Try to find any JSON object in the text
  257. brace_start = content.find('{')
  258. brace_end = content.rfind('}')
  259. if brace_start != -1 and brace_end > brace_start:
  260. try:
  261. json_str = content[brace_start:brace_end + 1]
  262. data = json.loads(json_str)
  263. return response_type.model_validate(data)
  264. except json.JSONDecodeError:
  265. pass
  266. raise ValueError(f"Failed to parse LLM response as {response_type.__name__}: {content[:200]}...")
  267. @property
  268. def active(self) -> str:
  269. """
  270. Get active model name
  271. Returns:
  272. Active model name
  273. Example:
  274. print(f"Using model: {pixelle_video.llm.active}")
  275. """
  276. return self._get_config_value("model", "gpt-3.5-turbo")
  277. def __repr__(self) -> str:
  278. """String representation"""
  279. model = self.active
  280. base_url = self._get_config_value("base_url", "default")
  281. return f"<LLMService model={model!r} base_url={base_url!r}>"