"""Small, credential-free request snapshots in the existing task trace store.""" import base64 from contextvars import ContextVar from copy import deepcopy import io import hashlib import logging import re import time from uuid import uuid4 from PIL import Image from artemis.data_engine.context_vars import CURRENT_NODE_NAME, CURRENT_TRACE_ID from artemis.llm.argument_fingerprint import ArgumentFingerprint, MAX_TRACKED_TOOLS from artemis.llm.final_review import ACTIVE_FINAL_REVIEW, FinalReviewBudgetExceeded from artemis.llm.reliability import LLMExhaustedError from artemis.llm.usage import usage_from_message from artemis.llm.stream_guard import ToolArgumentOverflow from artemis.llm.response_guard import ResponseIntegrityError from artemis.runtime.cancellation import CancellationReason, cancellation_reason logger = logging.getLogger(__name__) ACTIVE_ATTEMPT: ContextVar["RequestAttempt | None"] = ContextVar("llm_attempt", default=None) def _identifier(value): """Keep bounded, printable correlation IDs; never arbitrary header text.""" if isinstance(value, str) and re.fullmatch(r"[A-Za-z0-9_.:/-]{1,256}", value): return value return None def error_text(error: BaseException, timeout: float | None = None) -> str: if isinstance(error, FinalReviewBudgetExceeded): return str(error) if isinstance(error, TimeoutError): return f"模型请求在 {timeout:g} 秒内未完成" if timeout else "模型请求超时" text = str(error).strip() or type(error).__name__ text = re.sub( r"(?i)(bearer\s+|(?:api[_-]?key|token|authorization)[\"']?\s*[:=]\s*[\"']?)[^\s\"',;]+", r"\1[REDACTED]", text, ) text = re.sub(r"\bsk-[\w-]+", "[REDACTED]", text) text = re.sub(r"https?://[^\s\"']+", "[endpoint]", text) return text[:1000] def final_review_error_text(error: Exception) -> str: if isinstance(error, FinalReviewBudgetExceeded): return str(error) if isinstance(error, LLMExhaustedError): return f"验收模型请求重试已用尽({error.failure.category.value}):{error_text(error)}" return f"{type(error).__name__}: {error_text(error)}" def input_images(args: tuple, kwargs: dict) -> list[dict]: """Inspect only inline image headers; never fetch URLs or retain image data.""" images = [] messages = args[0] if args else kwargs.get("input", []) if not isinstance(messages, (list, tuple)): return images for message in messages: content = getattr(message, "content", None) if not isinstance(content, list): continue for block in content: if not isinstance(block, dict) or block.get("type") not in { "image_url", "input_image", "image", }: continue value = block.get("image_url", block.get("url", "")) value = value.get("url", "") if isinstance(value, dict) else value info = {"width": None, "height": None} if isinstance(value, str) and value.startswith("data:image/") and ";base64," in value: try: # JPEG/PNG dimensions normally fit in this prefix. Unknown is # preferable to decoding large images just for diagnostics. encoded = value.split(",", 1)[1][:131072] with Image.open(io.BytesIO(base64.b64decode(encoded))) as image: info.update(width=image.width, height=image.height) except (ValueError, OSError): pass images.append(info) return images class RequestAttempt: def __init__( self, engine, *, request_id, attempt, timeout, max_retries, metadata, previous_attempt_id=None, ): self.engine = engine self.trace_id = uuid4() self.step_id = getattr(engine, "current_step_id", None) self.parent_id = CURRENT_TRACE_ID.get() self.clock = time.monotonic() self.last_publish = self.clock self.tools: dict[int | str, dict] = {} self.tool_fingerprints: dict[int | str, ArgumentFingerprint] = {} self.review = ACTIVE_FINAL_REVIEW.get() if self.review: self.review.request_started(request_id, attempt) self.data = { "images": [], **metadata, "request_id": request_id, "attempt_id": str(self.trace_id), "previous_attempt_id": previous_attempt_id, "attempt": attempt, "max_retries": max_retries, "timeout_seconds": timeout, "role": CURRENT_NODE_NAME.get() or metadata.get("role") or "model", "started_at": time.time(), "phase": "waiting_response", "sequence": 0, "usage": None, "output_chunks": 0, "tool_argument_chars": 0, "tool_observation_layer": "langchain_tool_call_chunks", "argument_fingerprint_version": 1, "tool_stats_truncated": False, "tools": [], "step_id": str(self.step_id) if self.step_id else None, "parent_trace_id": str(self.parent_id) if self.parent_id else None, } self.publish() def publish(self): self.data["sequence"] += 1 self.data["updated_at"] = time.time() self.data["elapsed_seconds"] = round(time.monotonic() - self.clock, 3) self.last_publish = time.monotonic() if self.review: self.data["final_review"] = self.review.snapshot() if not self.engine or not getattr(self.engine, "current_session_id", None): return try: phase = self.data["phase"] status = ( "success" if phase == "completed" else "failed" if self.data.get("ended_at") else "running" ) self.engine.record_trace( type="llm_diagnostic", name="llm_request", trace_id=self.trace_id, step_id=self.step_id, parent_trace_id=self.parent_id, payload=deepcopy(self.data), status=status, duration=self.data["elapsed_seconds"], ) except (OSError, RuntimeError, ValueError, TypeError, AttributeError): logger.warning("Could not persist model request diagnostics", exc_info=True) def headers(self, response): self.data.update( headers_at=time.time(), http_status=response.status_code, server_request_id=_identifier(response.headers.get("x-oneapi-request-id")) or _identifier(response.headers.get("x-request-id")) or _identifier(response.headers.get("request-id")), response_x_request_id=_identifier(response.headers.get("x-request-id")), server_trace_id=_identifier(response.headers.get("x-trace-id")), phase="waiting_output", ) traceparent = response.headers.get("traceparent", "") self.data.pop("traceparent", None) if re.fullmatch(r"[0-9a-f]{2}-[0-9a-f]{32}-[0-9a-f]{16}-[0-9a-f]{2}", traceparent): self.data["traceparent"] = traceparent self.publish() def response_metadata(self, response): raw = response.get("raw") if isinstance(response, dict) else response metadata = getattr(raw, "response_metadata", {}) metadata = metadata if isinstance(metadata, dict) else {} if response_id := _identifier(metadata.get("id")): self.data["response_id"] = response_id for field in ("status", "finish_reason"): value = metadata.get(field) if isinstance(value, str) and re.fullmatch(r"[\w.-]{1,64}", value): self.data["response_" + field] = value def chunk(self, chunk): self.response_metadata(chunk) if (usage := usage_from_message(chunk)) is not None: self.data["usage"] = usage tool_chunks = getattr(chunk, "tool_call_chunks", None) or [] # Responses exposes the same argument delta in content and tool_call_chunks. # Count only the latter; retain bounded fingerprints, never argument bodies. for tool in tool_chunks: arguments = tool.get("args") if isinstance(arguments, str): self.data["tool_argument_chars"] += len(arguments) index = tool.get("index") call_id = _identifier(tool.get("id")) key = index if index is not None else call_id or "unknown" if key not in self.tools and len(self.tools) >= MAX_TRACKED_TOOLS: self.data["tool_stats_truncated"] = True continue stats = self.tools.setdefault(key, {"name": None}) if call_id: stats["call_id"] = call_id if index is not None: stats["chunk_index"] = index name = tool.get("name") if isinstance(name, str) and re.fullmatch(r"[\w.:-]{1,128}", name): stats["name"] = name fingerprint = self.tool_fingerprints.get(key) if fingerprint is None: fingerprint = self.tool_fingerprints[key] = ArgumentFingerprint() if isinstance(arguments, str): fingerprint.observe(arguments) stats.update(fingerprint.snapshot()) self.data["tools"] = list(self.tools.values()) content = getattr(chunk, "content", None) substantive = isinstance(content, str) and bool(content) if isinstance(content, list): substantive = any( isinstance(b, dict) and ( b.get("text") or b.get("thinking") or b.get("summary") or b.get("type") in {"tool_call", "function_call"} ) for b in content ) substantive = substantive or bool(tool_chunks) or bool(getattr(chunk, "tool_calls", None)) if not substantive: return now = time.time() first = not self.data.get("first_output_at") self.data.setdefault("first_output_at", now) phase = "generating_tool_arguments" if tool_chunks else "receiving_output" changed = self.data["phase"] != phase self.data.update(last_output_at=now, phase=phase) self.data["output_chunks"] += 1 if first or changed or time.monotonic() - self.last_publish >= 5: self.publish() def finish(self, phase, *, response=None, error=None, category=None): if category is not None: self.data["category"] = category if error is not None: if isinstance(error, ToolArgumentOverflow): self.data["stream_guard"] = error.details if isinstance(error, ResponseIntegrityError): self.data["response_guard"] = error.details self.data.update( error=error_text(error, self.data["timeout_seconds"]), error_type=type(error).__name__, failure_phase=self.data["phase"], ) status = getattr(error, "status_code", None) if isinstance(status, int): self.data["http_status"] = status if request_id := _identifier(getattr(error, "request_id", None)): self.data["error_request_id"] = request_id if not self.data.get("server_request_id"): self.data["server_request_id"] = request_id if category == "timeout": self.data.setdefault("timeout_scope", "transport") if response is not None: self.response_metadata(response) # The aggregate repeats all streaming deltas; do not count them twice. if not self.data.get("first_output_at"): self.chunk(response) if (usage := usage_from_message(response)) is not None: self.data["usage"] = usage self.data.update(phase=phase, ended_at=time.time()) if self.review: self.review.request_finished(phase, category) self.publish() def retry(self, delay): self.data.update( phase="retry_wait", retry_at=time.time() + delay, retry_delay_seconds=delay ) if self.review: self.review.request_finished("retry_wait") self.publish() def cancel(self, error: BaseException | None = None): reason = cancellation_reason(error) if self.review and self.review.expired and reason is CancellationReason.UNKNOWN: self.data["timeout_scope"] = "final_review" self.finish( "failed", error=FinalReviewBudgetExceeded(self.review.total_seconds), category="final_review_budget_exhausted", ) else: self.data.update(cancel_reason=reason.value, cancel_phase=self.data["phase"]) if reason is CancellationReason.OUTER_TIMEOUT: self.data["timeout_scope"] = "outer" else: self.data.pop("timeout_scope", None) # Preserve a retry's last failure, but never retain arbitrary text # from CancelledError.args (which may contain user guidance). self.finish("cancelled", category="cancelled") async def observe_request_headers(request): """Correlate a wire request and record only hashes of routing headers.""" attempt = ACTIVE_ATTEMPT.get() if attempt is None: return request.headers.setdefault("X-Client-Request-Id", attempt.data["request_id"]) request.headers.setdefault("X-Client-Attempt-Id", attempt.data["attempt_id"]) routing = {} for name in ("thread-id", "thread_id", "session-id", "session_id"): value = request.headers.get(name) if value is not None: routing[name] = hashlib.sha256(value.encode()).hexdigest()[:16] attempt.data.update( request_sent_at=time.time(), client_request_id=_identifier(request.headers.get("X-Client-Request-Id")), client_attempt_id=_identifier(request.headers.get("X-Client-Attempt-Id")), routing_header_fingerprints=routing, client_request_id_matches=request.headers.get("X-Client-Request-Id") == attempt.data["request_id"], client_attempt_id_matches=request.headers.get("X-Client-Attempt-Id") == attempt.data["attempt_id"], ) attempt.publish() async def observe_response_headers(response): """httpx hook: only allowlisted headers, never body/auth/request headers.""" attempt = ACTIVE_ATTEMPT.get() if attempt is not None: attempt.headers(response)