from __future__ import annotations from .shared import * class LLMEmptyContentError(ValueError): """Raised when the model still returns empty content after one forced retry.""" class LLMChatOpenAIMixin: @staticmethod def _append_direct_answer_retry_instruction(messages: list[dict[str, str]]) -> None: retry_instruction = "请直接输出答案" for idx in range(len(messages) - 1, -1, -1): row = messages[idx] if not isinstance(row, dict): continue role = str(row.get("role") or "").strip().lower() if role != "user": continue original = str(row.get("content") or "").strip() if retry_instruction in original: return patched = dict(row) patched["content"] = f"{original}\n\n{retry_instruction}" if original else retry_instruction messages[idx] = patched return messages.append({"role": "user", "content": retry_instruction}) async def _chat_completion_via_openai_compatible( self, *, key: str, base_url: str, model: str, temperature: float, max_tokens: int, messages: list[dict[str, str]], request_timeout: float, structured_output: bool, function_label: str, provider_name: str, is_control_plane_gateway_request: bool, control_plane_agents_timeout_seconds: int, request_started_at_perf: float, request_trace_id: str, control_plane_agent_timeout: Optional[int], ) -> ChatCompletionResult: endpoint = f"{base_url.rstrip('/')}/chat/completions" headers = { "Authorization": f"Bearer {key}", "Content-Type": "application/json", } current_max_tokens = max(int(max_tokens or 0), 1) http_status_retry_budget = self._http_status_retry_budget_for_function(function_label) http_status_retry_attempt = 0 network_retry_budget = 1 network_retry_attempt = 0 empty_chat_retry_budget = self._empty_chat_retry_budget( function_name=function_label, current_max_tokens=current_max_tokens, ) use_json_response_format = bool(structured_output) direct_answer_retry_used = False while True: payload = { "model": model, "messages": messages, "temperature": temperature, "max_tokens": current_max_tokens, } if use_json_response_format: payload["response_format"] = {"type": "json_object"} if is_control_plane_gateway_request: payload["agents"] = { "defaults": { "timeoutSeconds": int(control_plane_agents_timeout_seconds), } } try: async with httpx.AsyncClient(timeout=request_timeout, trust_env=False) as client: resp = await client.post(endpoint, headers=headers, json=payload) except httpx.RequestError as exc: if network_retry_budget > 0: retry_delay = min(0.8 * (2 ** network_retry_attempt), 3.0) _runtime_log( "LLM", ( "网络异常重试:" f"function={function_label}, " f"retry_in={retry_delay:.2f}s, left={network_retry_budget - 1}, " f"error={type(exc).__name__}" ), ) logger.warning( "llm_network_retry trace_id=%s function_name=%s retry_attempt=%s retry_delay_seconds=%.2f error_type=%s error=%s", request_trace_id, function_label, network_retry_attempt, retry_delay, type(exc).__name__, str(exc)[:260], ) network_retry_budget -= 1 network_retry_attempt += 1 await asyncio.sleep(retry_delay) continue raise else: if is_control_plane_gateway_request: elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( "PMFlow 本地路由层调用", ( f"http ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} status={resp.status_code} elapsed_ms={elapsed_ms:.0f}" ), ) if resp.status_code >= 400: body_preview = str(resp.text or "").replace("\n", " ").strip()[:260] if is_control_plane_gateway_request: _runtime_log( "PMFlow 本地路由层调用", ( f"http_error ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} status={resp.status_code} body={body_preview}" ), ) status_code = int(resp.status_code) can_retry_http_status = ( http_status_retry_budget > 0 and self._is_retryable_http_status(status_code) ) if can_retry_http_status: retry_delay = self._http_retry_delay_seconds( status_code=status_code, attempt=http_status_retry_attempt, retry_after_header=resp.headers.get("Retry-After"), ) _runtime_log( "LLM", ( "HTTP状态重试:" f"function={function_label}, " f"status={status_code}, retry_in={retry_delay:.2f}s, " f"left={http_status_retry_budget - 1}" ), ) http_status_retry_budget -= 1 http_status_retry_attempt += 1 await asyncio.sleep(retry_delay) continue if use_json_response_format and status_code in {400, 422}: lower_preview = body_preview.lower() if "response_format" in lower_preview or "json_object" in lower_preview or "json schema" in lower_preview: _runtime_log( "LLM", ( "结构化参数降级重试:" f"function={function_label}, status={status_code}" ), ) use_json_response_format = False continue resp.raise_for_status() data = resp.json() choices = data.get("choices") or [] if not choices: raise ValueError("LLM 响应无 choices") first_choice = choices[0] if isinstance(choices[0], dict) else {} message = first_choice.get("message") if isinstance(first_choice, dict) else {} text = self._extract_text_from_chat_response( message=message, choice=first_choice, response=data, structured_output=structured_output, ) if not text and structured_output: reasoning_text = self._extract_reasoning_fallback_text( message=message, choice=first_choice, response=data, ) normalized_reasoning = self._strip_code_fence(reasoning_text) if self._contains_json_container(normalized_reasoning): candidate = self._extract_json_candidate(normalized_reasoning) if candidate: _runtime_log( "LLM", ( "结构化reasoning_content回退:" f"function={function_label}, provider={provider_name}" ), ) text = candidate if text: break msg_keys = list(message.keys()) if isinstance(message, dict) else [] choice_keys = list(first_choice.keys()) if isinstance(first_choice, dict) else [] choice_preview = "" try: choice_preview = json.dumps(first_choice, ensure_ascii=False)[:300] except Exception: choice_preview = str(first_choice)[:300] empty_error = LLMEmptyContentError( "LLM 返回空内容: " f"message_keys={msg_keys}, choice_keys={choice_keys}, choice_preview={choice_preview}" ) if not direct_answer_retry_used: direct_answer_retry_used = True self._append_direct_answer_retry_instruction(messages) _runtime_log( "LLM", ( "空content定向重试:" f"function={function_label}, retry_with=请直接输出答案" ), ) continue if empty_chat_retry_budget > 0 and self._should_retry_empty_chat_response( structured_output=structured_output, message=message, choice=first_choice, current_max_tokens=current_max_tokens, ): next_max_tokens = self._next_retry_max_tokens_for_empty_chat(current_max_tokens) if next_max_tokens > current_max_tokens: empty_chat_retry_budget -= 1 current_max_tokens = next_max_tokens _runtime_log( "LLM", ( "空content扩容重试:" f"function={function_label}, next_max_tokens={current_max_tokens}, " f"retries_left={empty_chat_retry_budget}" ), ) continue raise empty_error if _is_control_plane_timeout_message(text): elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( "PMFlow 本地路由层超时" if is_control_plane_gateway_request else "LLM超时文本", ( f"detected ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} elapsed_ms={elapsed_ms:.0f} " f"text={text.replace(chr(10), ' ')[:260]}" ), ) logger.warning( "llm_timeout_payload_detected", trace_id=request_trace_id, function_name=function_label, request_timeout_seconds=float(request_timeout), agent_timeout_seconds=control_plane_agent_timeout, text_preview=text.replace("\n", " ")[:260], ) if is_control_plane_gateway_request: elapsed_ms = (time.perf_counter() - request_started_at_perf) * 1000.0 _runtime_log( "PMFlow 本地路由层调用", ( f"end ts={_cn_now_text_ms()} trace_id={request_trace_id} " f"function={function_label} elapsed_ms={elapsed_ms:.0f} text_len={len(text)}" ), ) return ChatCompletionResult(text=text, raw_response=data)