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