Files

1 line
1.1 MiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{"repo": "basebase-ai/basebase", "files": {"backend/services/llm_adapter.py": {"75ba1d6227493073ab2e369aaabfeb81d1a2a443": {"t": 1785746642.767834, "n": 46845, "h": "099fcb29e29ec4c6", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol, TypedDict\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\nANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES: tuple[str, ...] = (\n \"claude-mythos-preview\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-4-6\",\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef supports_anthropic_adaptive_thinking(model: str) -> bool:\n \"\"\"Return whether an Anthropic-family model accepts adaptive thinking.\"\"\"\n return _normalized_model_name(model).startswith(\n ANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES\n )\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef _is_image_block(block: Any) -> bool:\n \"\"\"Return whether a content block carries image bytes/URLs.\"\"\"\n if not isinstance(block, dict):\n return False\n block_type: str = str(block.get(\"type\", \"\")).lower()\n return block_type in {\"image\", \"image_url\"}\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n if any(_is_image_block(block) for block in content):\n return True\n return False\n\n\ndef _content_text(content: Any) -> str:\n \"\"\"Extract user-visible text from common message content shapes.\"\"\"\n if isinstance(content, str):\n return content\n if not isinstance(content, list):\n return \"\"\n\n parts: list[str] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type = block.get(\"type\")\n if block_type == \"text\":\n parts.append(str(block.get(\"text\", \"\")))\n elif block_type == \"tool_result\":\n parts.append(str(block.get(\"content\", \"\")))\n return \"\\n\".join(parts)\n\n\ndef _is_text_only_image_warning(content: Any) -> bool:\n \"\"\"Detect assistant turns that already warned about text-only image support.\"\"\"\n text = _content_text(content).lower()\n if not text:\n return False\n return (\n \"can’t process image attachments\" in text\n or \"can't process image attachments\" in text\n or \"haven’t analyzed the image\" in text\n or \"haven't analyzed the image\" in text\n )\n\n\ndef messages_contain_unwarned_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Return whether images appear after the latest text-only image warning.\n\n Images are intentionally preserved in stored conversation history. For\n text-only models, a prior assistant warning marks the earlier image-bearing\n user turn as acknowledged, so later model calls can silently omit those\n image blocks instead of repeating the same refusal.\n \"\"\"\n has_image_since_warning = False\n for msg in messages:\n role = msg.get(\"role\")\n content: Any = msg.get(\"content\")\n if isinstance(content, list) and any(\n _is_image_block(block) for block in content\n ):\n has_image_since_warning = True\n continue\n\n if (\n role == \"assistant\"\n and has_image_since_warning\n and _is_text_only_image_warning(content)\n ):\n has_image_since_warning = False\n\n return has_image_since_warning\n\n\ndef strip_image_blocks_for_text_only_model(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Return a copy of messages with image blocks removed for text-only models.\"\"\"\n stripped_messages: list[dict[str, Any]] = []\n removed_count = 0\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n stripped_messages.append(msg)\n continue\n\n filtered_content: list[Any] = []\n for block in content:\n if _is_image_block(block):\n removed_count += 1\n continue\n filtered_content.append(block)\n\n if len(filtered_content) == len(content):\n stripped_messages.append(msg)\n elif filtered_content:\n stripped_messages.append({**msg, \"content\": filtered_content})\n\n if removed_count:\n logger.info(\n \"Dropped image blocks before text-only model dispatch\",\n extra={\"removed_image_blocks\": removed_count},\n )\n return stripped_messages\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n# ---------------------------------------------------------------------------\n# OpenAI-compatible thinking capabilities (declarative)\n#\n# Each entry maps a model-prefix to the extra kwargs needed for reasoning.\n# `reasoning_effort` is only included when the provider actually accepts it as\n# a top-level parameter (OpenAI o-series). DeepSeek uses `extra_body.thinking`\n# instead. To add a new provider, add a row — no branching required.\n# ---------------------------------------------------------------------------\n\n\nclass _OpenAIThinkingCaps(TypedDict, total=False):\n reasoning_effort: str | None\n thinking_body_key: str | None\n\n\n_OPENAI_THINKING_CAPS_TABLE: list[tuple[tuple[str, ...], _OpenAIThinkingCaps]] = [\n # DeepSeek — uses extra_body.thinking, no reasoning_effort top-level param\n (DEEPSEEK_MODEL_PREFIXES, {\"reasoning_effort\": None, \"thinking_body_key\": \"thinking\"}),\n # OpenAI o-series — uses reasoning_effort, no extra_body thinking\n ((\"o1\", \"o3\", \"o4\"), {\"reasoning_effort\": \"high\", \"thinking_body_key\": None}),\n]\n\n\ndef _resolve_openai_thinking_caps(model: str) -> _OpenAIThinkingCaps | None:\n \"\"\"Look up thinking capabilities for an OpenAI-compat model by prefix.\"\"\"\n normalized: str = _normalized_model_name(model)\n for prefixes, caps in _OPENAI_THINKING_CAPS_TABLE:\n if normalized.startswith(prefixes):\n return caps\n return None\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n api_kwargs.update(self._build_thinking_kwargs(model=model, thinking=thinking))\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return Anthropic Messages API thinking options supported by the model.\"\"\"\n if not thinking:\n return {}\n if supports_anthropic_adaptive_thinking(model):\n logger.debug(\n \"Anthropic adaptive thinking controls selected\",\n extra={\"model\": model, \"thinking_type\": \"adaptive\"},\n )\n return {\"thinking\": {\"type\": \"adaptive\"}}\n\n logger.info(\n \"Skipping Anthropic adaptive thinking for unsupported model\",\n extra={\"model\": model, \"thinking_requested\": thinking},\n )\n return {}\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\n\n Uses a declarative capabilities table so adding future providers or\n models only requires a new entry — no branching logic.\n \"\"\"\n caps: _OpenAIThinkingCaps | None = _resolve_openai_thinking_caps(model)\n if caps is None:\n return {}\n\n thinking_type: str = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"OpenAI-compat thinking controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type, \"caps\": caps},\n )\n kwargs: dict[str, Any] = {}\n if caps.get(\"reasoning_effort\"):\n kwargs[\"reasoning_effort\"] = caps[\"reasoning_effort\"]\n extra_body: dict[str, Any] = {}\n if caps.get(\"thinking_body_key\"):\n extra_body[caps[\"thinking_body_key\"]] = {\"type\": thinking_type}\n if extra_body:\n kwargs[\"extra_body\"] = extra_body\n return kwargs\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "b0a38474e1b5cfa6954ea26a8f9ad71f07034949": {"t": 1785766527.278633, "n": 46845, "h": "099fcb29e29ec4c6", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol, TypedDict\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\nANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES: tuple[str, ...] = (\n \"claude-mythos-preview\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-4-6\",\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef supports_anthropic_adaptive_thinking(model: str) -> bool:\n \"\"\"Return whether an Anthropic-family model accepts adaptive thinking.\"\"\"\n return _normalized_model_name(model).startswith(\n ANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES\n )\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef _is_image_block(block: Any) -> bool:\n \"\"\"Return whether a content block carries image bytes/URLs.\"\"\"\n if not isinstance(block, dict):\n return False\n block_type: str = str(block.get(\"type\", \"\")).lower()\n return block_type in {\"image\", \"image_url\"}\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n if any(_is_image_block(block) for block in content):\n return True\n return False\n\n\ndef _content_text(content: Any) -> str:\n \"\"\"Extract user-visible text from common message content shapes.\"\"\"\n if isinstance(content, str):\n return content\n if not isinstance(content, list):\n return \"\"\n\n parts: list[str] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type = block.get(\"type\")\n if block_type == \"text\":\n parts.append(str(block.get(\"text\", \"\")))\n elif block_type == \"tool_result\":\n parts.append(str(block.get(\"content\", \"\")))\n return \"\\n\".join(parts)\n\n\ndef _is_text_only_image_warning(content: Any) -> bool:\n \"\"\"Detect assistant turns that already warned about text-only image support.\"\"\"\n text = _content_text(content).lower()\n if not text:\n return False\n return (\n \"can’t process image attachments\" in text\n or \"can't process image attachments\" in text\n or \"haven’t analyzed the image\" in text\n or \"haven't analyzed the image\" in text\n )\n\n\ndef messages_contain_unwarned_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Return whether images appear after the latest text-only image warning.\n\n Images are intentionally preserved in stored conversation history. For\n text-only models, a prior assistant warning marks the earlier image-bearing\n user turn as acknowledged, so later model calls can silently omit those\n image blocks instead of repeating the same refusal.\n \"\"\"\n has_image_since_warning = False\n for msg in messages:\n role = msg.get(\"role\")\n content: Any = msg.get(\"content\")\n if isinstance(content, list) and any(\n _is_image_block(block) for block in content\n ):\n has_image_since_warning = True\n continue\n\n if (\n role == \"assistant\"\n and has_image_since_warning\n and _is_text_only_image_warning(content)\n ):\n has_image_since_warning = False\n\n return has_image_since_warning\n\n\ndef strip_image_blocks_for_text_only_model(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Return a copy of messages with image blocks removed for text-only models.\"\"\"\n stripped_messages: list[dict[str, Any]] = []\n removed_count = 0\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n stripped_messages.append(msg)\n continue\n\n filtered_content: list[Any] = []\n for block in content:\n if _is_image_block(block):\n removed_count += 1\n continue\n filtered_content.append(block)\n\n if len(filtered_content) == len(content):\n stripped_messages.append(msg)\n elif filtered_content:\n stripped_messages.append({**msg, \"content\": filtered_content})\n\n if removed_count:\n logger.info(\n \"Dropped image blocks before text-only model dispatch\",\n extra={\"removed_image_blocks\": removed_count},\n )\n return stripped_messages\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n# ---------------------------------------------------------------------------\n# OpenAI-compatible thinking capabilities (declarative)\n#\n# Each entry maps a model-prefix to the extra kwargs needed for reasoning.\n# `reasoning_effort` is only included when the provider actually accepts it as\n# a top-level parameter (OpenAI o-series). DeepSeek uses `extra_body.thinking`\n# instead. To add a new provider, add a row — no branching required.\n# ---------------------------------------------------------------------------\n\n\nclass _OpenAIThinkingCaps(TypedDict, total=False):\n reasoning_effort: str | None\n thinking_body_key: str | None\n\n\n_OPENAI_THINKING_CAPS_TABLE: list[tuple[tuple[str, ...], _OpenAIThinkingCaps]] = [\n # DeepSeek — uses extra_body.thinking, no reasoning_effort top-level param\n (DEEPSEEK_MODEL_PREFIXES, {\"reasoning_effort\": None, \"thinking_body_key\": \"thinking\"}),\n # OpenAI o-series — uses reasoning_effort, no extra_body thinking\n ((\"o1\", \"o3\", \"o4\"), {\"reasoning_effort\": \"high\", \"thinking_body_key\": None}),\n]\n\n\ndef _resolve_openai_thinking_caps(model: str) -> _OpenAIThinkingCaps | None:\n \"\"\"Look up thinking capabilities for an OpenAI-compat model by prefix.\"\"\"\n normalized: str = _normalized_model_name(model)\n for prefixes, caps in _OPENAI_THINKING_CAPS_TABLE:\n if normalized.startswith(prefixes):\n return caps\n return None\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n api_kwargs.update(self._build_thinking_kwargs(model=model, thinking=thinking))\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return Anthropic Messages API thinking options supported by the model.\"\"\"\n if not thinking:\n return {}\n if supports_anthropic_adaptive_thinking(model):\n logger.debug(\n \"Anthropic adaptive thinking controls selected\",\n extra={\"model\": model, \"thinking_type\": \"adaptive\"},\n )\n return {\"thinking\": {\"type\": \"adaptive\"}}\n\n logger.info(\n \"Skipping Anthropic adaptive thinking for unsupported model\",\n extra={\"model\": model, \"thinking_requested\": thinking},\n )\n return {}\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\n\n Uses a declarative capabilities table so adding future providers or\n models only requires a new entry — no branching logic.\n \"\"\"\n caps: _OpenAIThinkingCaps | None = _resolve_openai_thinking_caps(model)\n if caps is None:\n return {}\n\n thinking_type: str = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"OpenAI-compat thinking controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type, \"caps\": caps},\n )\n kwargs: dict[str, Any] = {}\n if caps.get(\"reasoning_effort\"):\n kwargs[\"reasoning_effort\"] = caps[\"reasoning_effort\"]\n extra_body: dict[str, Any] = {}\n if caps.get(\"thinking_body_key\"):\n extra_body[caps[\"thinking_body_key\"]] = {\"type\": thinking_type}\n if extra_body:\n kwargs[\"extra_body\"] = extra_body\n return kwargs\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "12e6b184ca290fa76652434b94549be1584f526b": {"t": 1785766529.3097987, "n": 44943, "h": "69421022b8928e21", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\nANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES: tuple[str, ...] = (\n \"claude-mythos-preview\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-4-6\",\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef supports_anthropic_adaptive_thinking(model: str) -> bool:\n \"\"\"Return whether an Anthropic-family model accepts adaptive thinking.\"\"\"\n return _normalized_model_name(model).startswith(\n ANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES\n )\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef _is_image_block(block: Any) -> bool:\n \"\"\"Return whether a content block carries image bytes/URLs.\"\"\"\n if not isinstance(block, dict):\n return False\n block_type: str = str(block.get(\"type\", \"\")).lower()\n return block_type in {\"image\", \"image_url\"}\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n if any(_is_image_block(block) for block in content):\n return True\n return False\n\n\ndef _content_text(content: Any) -> str:\n \"\"\"Extract user-visible text from common message content shapes.\"\"\"\n if isinstance(content, str):\n return content\n if not isinstance(content, list):\n return \"\"\n\n parts: list[str] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type = block.get(\"type\")\n if block_type == \"text\":\n parts.append(str(block.get(\"text\", \"\")))\n elif block_type == \"tool_result\":\n parts.append(str(block.get(\"content\", \"\")))\n return \"\\n\".join(parts)\n\n\ndef _is_text_only_image_warning(content: Any) -> bool:\n \"\"\"Detect assistant turns that already warned about text-only image support.\"\"\"\n text = _content_text(content).lower()\n if not text:\n return False\n return (\n \"can’t process image attachments\" in text\n or \"can't process image attachments\" in text\n or \"haven’t analyzed the image\" in text\n or \"haven't analyzed the image\" in text\n )\n\n\ndef messages_contain_unwarned_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Return whether images appear after the latest text-only image warning.\n\n Images are intentionally preserved in stored conversation history. For\n text-only models, a prior assistant warning marks the earlier image-bearing\n user turn as acknowledged, so later model calls can silently omit those\n image blocks instead of repeating the same refusal.\n \"\"\"\n has_image_since_warning = False\n for msg in messages:\n role = msg.get(\"role\")\n content: Any = msg.get(\"content\")\n if isinstance(content, list) and any(\n _is_image_block(block) for block in content\n ):\n has_image_since_warning = True\n continue\n\n if (\n role == \"assistant\"\n and has_image_since_warning\n and _is_text_only_image_warning(content)\n ):\n has_image_since_warning = False\n\n return has_image_since_warning\n\n\ndef strip_image_blocks_for_text_only_model(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Return a copy of messages with image blocks removed for text-only models.\"\"\"\n stripped_messages: list[dict[str, Any]] = []\n removed_count = 0\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n stripped_messages.append(msg)\n continue\n\n filtered_content: list[Any] = []\n for block in content:\n if _is_image_block(block):\n removed_count += 1\n continue\n filtered_content.append(block)\n\n if len(filtered_content) == len(content):\n stripped_messages.append(msg)\n elif filtered_content:\n stripped_messages.append({**msg, \"content\": filtered_content})\n\n if removed_count:\n logger.info(\n \"Dropped image blocks before text-only model dispatch\",\n extra={\"removed_image_blocks\": removed_count},\n )\n return stripped_messages\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n api_kwargs.update(self._build_thinking_kwargs(model=model, thinking=thinking))\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return Anthropic Messages API thinking options supported by the model.\"\"\"\n if not thinking:\n return {}\n if supports_anthropic_adaptive_thinking(model):\n logger.debug(\n \"Anthropic adaptive thinking controls selected\",\n extra={\"model\": model, \"thinking_type\": \"adaptive\"},\n )\n return {\"thinking\": {\"type\": \"adaptive\"}}\n\n logger.info(\n \"Skipping Anthropic adaptive thinking for unsupported model\",\n extra={\"model\": model, \"thinking_requested\": thinking},\n )\n return {}\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "14c50d72a01fac3936260233c0bd663c77fa25bd": {"t": 1785766531.3665488, "n": 44926, "h": "cad58a232008a9f1", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\nANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES: tuple[str, ...] = (\n \"claude-mythos-preview\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-4-6\",\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef supports_anthropic_adaptive_thinking(model: str) -> bool:\n \"\"\"Return whether an Anthropic-family model accepts adaptive thinking.\"\"\"\n return _normalized_model_name(model).startswith(\n ANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES\n )\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef _is_image_block(block: Any) -> bool:\n \"\"\"Return whether a content block carries image bytes/URLs.\"\"\"\n if not isinstance(block, dict):\n return False\n block_type: str = str(block.get(\"type\", \"\")).lower()\n return block_type in {\"image\", \"image_url\"}\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n if any(_is_image_block(block) for block in content):\n return True\n return False\n\n\ndef _content_text(content: Any) -> str:\n \"\"\"Extract user-visible text from common message content shapes.\"\"\"\n if isinstance(content, str):\n return content\n if not isinstance(content, list):\n return \"\"\n\n parts: list[str] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type = block.get(\"type\")\n if block_type == \"text\":\n parts.append(str(block.get(\"text\", \"\")))\n elif block_type == \"tool_result\":\n parts.append(str(block.get(\"content\", \"\")))\n return \"\\n\".join(parts)\n\n\ndef _is_text_only_image_warning(content: Any) -> bool:\n \"\"\"Detect assistant turns that already warned about text-only image support.\"\"\"\n text = _content_text(content).lower()\n if not text:\n return False\n return (\n \"can’t process image attachments\" in text\n or \"can't process image attachments\" in text\n or \"haven’t analyzed the image\" in text\n or \"haven't analyzed the image\" in text\n )\n\n\ndef messages_contain_unwarned_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Return whether images appear after the latest text-only image warning.\n\n Images are intentionally preserved in stored conversation history. For\n text-only models, a prior assistant warning marks the earlier image-bearing\n user turn as acknowledged, so later model calls can silently omit those\n image blocks instead of repeating the same refusal.\n \"\"\"\n has_image_since_warning = False\n for msg in messages:\n role = msg.get(\"role\")\n content: Any = msg.get(\"content\")\n if isinstance(content, list) and any(\n _is_image_block(block) for block in content\n ):\n has_image_since_warning = True\n continue\n\n if (\n role == \"assistant\"\n and has_image_since_warning\n and _is_text_only_image_warning(content)\n ):\n has_image_since_warning = False\n\n return has_image_since_warning\n\n\ndef strip_image_blocks_for_text_only_model(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Return a copy of messages with image blocks removed for text-only models.\"\"\"\n stripped_messages: list[dict[str, Any]] = []\n removed_count = 0\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n stripped_messages.append(msg)\n continue\n\n filtered_content: list[Any] = []\n for block in content:\n if _is_image_block(block):\n removed_count += 1\n continue\n filtered_content.append(block)\n\n if len(filtered_content) == len(content):\n stripped_messages.append(msg)\n else:\n stripped_messages.append({**msg, \"content\": filtered_content})\n\n if removed_count:\n logger.info(\n \"Dropped image blocks before text-only model dispatch\",\n extra={\"removed_image_blocks\": removed_count},\n )\n return stripped_messages\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n api_kwargs.update(self._build_thinking_kwargs(model=model, thinking=thinking))\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return Anthropic Messages API thinking options supported by the model.\"\"\"\n if not thinking:\n return {}\n if supports_anthropic_adaptive_thinking(model):\n logger.debug(\n \"Anthropic adaptive thinking controls selected\",\n extra={\"model\": model, \"thinking_type\": \"adaptive\"},\n )\n return {\"thinking\": {\"type\": \"adaptive\"}}\n\n logger.info(\n \"Skipping Anthropic adaptive thinking for unsupported model\",\n extra={\"model\": model, \"thinking_requested\": thinking},\n )\n return {}\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(prepared_messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n prepared_messages = self._prepare_messages_for_model(\n model=model, messages=messages\n )\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(prepared_messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending new images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_unwarned_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def _prepare_messages_for_model(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Validate image support and strip already-warned images if needed.\"\"\"\n self._guard_model_image_support(model=model, messages=messages)\n if model_image_unsupported_message(model) is None:\n return messages\n return strip_image_blocks_for_text_only_model(messages)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "f3df505b93d91fb65ae5e0f5cb74b792ce0d10e4": {"t": 1785766533.733717, "n": 40649, "h": "207073a224bf8b9c", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\nANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES: tuple[str, ...] = (\n \"claude-mythos-preview\",\n \"claude-opus-4-7\",\n \"claude-opus-4-6\",\n \"claude-sonnet-4-6\",\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef supports_anthropic_adaptive_thinking(model: str) -> bool:\n \"\"\"Return whether an Anthropic-family model accepts adaptive thinking.\"\"\"\n return _normalized_model_name(model).startswith(\n ANTHROPIC_ADAPTIVE_THINKING_MODEL_PREFIXES\n )\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type: str = str(block.get(\"type\", \"\")).lower()\n if block_type in {\"image\", \"image_url\"}:\n return True\n return False\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n api_kwargs.update(self._build_thinking_kwargs(model=model, thinking=thinking))\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return Anthropic Messages API thinking options supported by the model.\"\"\"\n if not thinking:\n return {}\n if supports_anthropic_adaptive_thinking(model):\n logger.debug(\n \"Anthropic adaptive thinking controls selected\",\n extra={\"model\": model, \"thinking_type\": \"adaptive\"},\n )\n return {\"thinking\": {\"type\": \"adaptive\"}}\n\n logger.info(\n \"Skipping Anthropic adaptive thinking for unsupported model\",\n extra={\"model\": model, \"thinking_requested\": thinking},\n )\n return {}\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "45c2bde12a1ed4976cf79de74e58956be17d8949": {"t": 1785766535.4213316, "n": 39527, "h": "52fa1cc952991b95", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type: str = str(block.get(\"type\", \"\")).lower()\n if block_type in {\"image\", \"image_url\"}:\n return True\n return False\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "3071730c7598faa99e20d30f8ceae88dc87cc743": {"t": 1785766537.1940346, "n": 40259, "h": "6081a90004325943", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type: str = str(block.get(\"type\", \"\")).lower()\n if block_type in {\"image\", \"image_url\"}:\n return True\n return False\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n supports_adaptive_thinking: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_adaptive_thinking: bool = supports_adaptive_thinking\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n api_kwargs.update(self._build_thinking_kwargs(model=model, thinking=thinking))\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return Anthropic-compatible thinking controls supported by this adapter.\"\"\"\n if not thinking:\n return {}\n if self._supports_adaptive_thinking:\n return {\"thinking\": {\"type\": \"adaptive\"}}\n\n logger.info(\n \"Skipping adaptive thinking for Anthropic-compatible provider without support\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n return {}\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_adaptive_thinking=provider == \"anthropic\",\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "5a5757780e2e1cc290d84be885835f1dc55f33e5": {"t": 1785766539.239076, "n": 39527, "h": "52fa1cc952991b95", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\nMINIMAX_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with MiniMax. I haven’t analyzed \"\n \"the image in this turn. Please switch to a vision-capable model, or \"\n \"resend your message without images.\"\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_minimax_model(model: str) -> bool:\n \"\"\"Return whether a model name targets MiniMax's Anthropic-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(\"minimax\")\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef model_image_unsupported_message(model: str) -> str | None:\n \"\"\"Return a local image unsupported warning for known text-only models.\"\"\"\n if deepseek_v4_rejects_images(model):\n return DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE\n if is_minimax_model(model):\n return MINIMAX_IMAGE_UNSUPPORTED_MESSAGE\n return None\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type: str = str(block.get(\"type\", \"\")).lower()\n if block_type in {\"image\", \"image_url\"}:\n return True\n return False\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n unsupported_message = model_image_unsupported_message(model)\n if unsupported_message is None or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only model\",\n extra={\"model\": model, \"adapter\": self.__class__.__name__},\n )\n raise DeepSeekImageUnsupportedError(unsupported_message)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "b18a4399fd6ce685b39b27b9127b4ffa0d967241": {"t": 1785766540.738816, "n": 38504, "h": "a6af2545d5097c9d", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"I can’t process image attachments with DeepSeek-V4-Pro. I haven’t analyzed \"\n \"the image in this turn. Please switch to DeepSeek-V4-Vision or another \"\n \"vision-capable model, or resend your message without images.\"\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only DeepSeek v4 model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type: str = str(block.get(\"type\", \"\")).lower()\n if block_type in {\"image\", \"image_url\"}:\n return True\n return False\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n if not deepseek_v4_rejects_images(model) or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only DeepSeek v4 model\",\n extra={\"model\": model},\n )\n raise DeepSeekImageUnsupportedError(DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n if not deepseek_v4_rejects_images(model) or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only DeepSeek v4 model\",\n extra={\"model\": model},\n )\n raise DeepSeekImageUnsupportedError(DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "8835c894f67768797a5a5ebcfe099cca165703d7": {"t": 1785766542.4244137, "n": 38445, "h": "8bd812fff9cfe714", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"DeepSeek v4 does not accept image attachments. Please switch to \"\n \"DeepSeek-V4-Vision or another vision-capable model, or resend your message without images.\"\n)\n\n\nclass DeepSeekImageUnsupportedError(ValueError):\n \"\"\"Raised when a text-only DeepSeek v4 model receives image inputs.\"\"\"\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type: str = str(block.get(\"type\", \"\")).lower()\n if block_type in {\"image\", \"image_url\"}:\n return True\n return False\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n if not deepseek_v4_rejects_images(model) or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only DeepSeek v4 model\",\n extra={\"model\": model},\n )\n raise DeepSeekImageUnsupportedError(DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n if not deepseek_v4_rejects_images(model) or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only DeepSeek v4 model\",\n extra={\"model\": model},\n )\n raise DeepSeekImageUnsupportedError(DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "d0315be2d45d7490b45dbaea1eac26d0249ac013": {"t": 1785766545.4690764, "n": 38281, "h": "7fac9afac4744b9e", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\nDEEPSEEK_V4_VISION_MODEL_PREFIX: str = \"deepseek-v4-vision\"\nDEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE: str = (\n \"DeepSeek v4 does not accept image attachments. Please switch to \"\n \"DeepSeek-V4-Vision or another vision-capable model, or resend your message without images.\"\n)\n\n\ndef _normalized_model_name(model: str) -> str:\n \"\"\"Normalize provider-prefixed model names for local capability checks.\"\"\"\n return model.strip().lower().split(\"/\")[-1]\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef is_deepseek_v4_vision_model(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model explicitly supports image inputs.\"\"\"\n return _normalized_model_name(model).startswith(DEEPSEEK_V4_VISION_MODEL_PREFIX)\n\n\ndef deepseek_v4_rejects_images(model: str) -> bool:\n \"\"\"Return whether a DeepSeek v4 model is text-only for image inputs.\"\"\"\n normalized_model: str = _normalized_model_name(model)\n return normalized_model.startswith(\"deepseek-v4\") and not is_deepseek_v4_vision_model(model)\n\n\ndef messages_contain_images(messages: list[dict[str, Any]]) -> bool:\n \"\"\"Detect image content blocks before dispatching to a provider API.\"\"\"\n for msg in messages:\n content: Any = msg.get(\"content\")\n if not isinstance(content, list):\n continue\n for block in content:\n if not isinstance(block, dict):\n continue\n block_type: str = str(block.get(\"type\", \"\")).lower()\n if block_type in {\"image\", \"image_url\"}:\n return True\n return False\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n if not deepseek_v4_rejects_images(model) or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only DeepSeek v4 model\",\n extra={\"model\": model},\n )\n raise ValueError(DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n self._guard_model_image_support(model=model, messages=messages)\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def _guard_model_image_support(\n self, *, model: str, messages: list[dict[str, Any]]\n ) -> None:\n \"\"\"Fail locally before sending images to text-only model variants.\"\"\"\n if not deepseek_v4_rejects_images(model) or not messages_contain_images(messages):\n return\n\n logger.info(\n \"Rejected image-bearing request for text-only DeepSeek v4 model\",\n extra={\"model\": model},\n )\n raise ValueError(DEEPSEEK_V4_IMAGE_UNSUPPORTED_MESSAGE)\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "60b4ee83cb596ec134c895ebae3a65a838bcfb78": {"t": 1785766547.1335847, "n": 35673, "h": "20e5fafe8f712917", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n return normalized_model.startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "4eeaa7b9a2ed8c1e8fbe85e02e336ba247e4cb71": {"t": 1785766548.9957933, "n": 35864, "h": "c34c86c8dc129012", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n return normalized_model.startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n active_model: str = model\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n active_model = candidate_model\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if is_deepseek_model(active_model) and reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n reasoning_content = _get_attr_or_item(message, \"reasoning_content\")\n if reasoning_content:\n blocks.append(ContentBlock(type=\"thinking\", thinking=reasoning_content))\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "d850a7e06e0a07e95e2217691fc29027b75bfba5": {"t": 1785766550.7001457, "n": 35747, "h": "18f45a9eff81ae91", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n return normalized_model.startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\ndef _get_attr_or_item(value: Any, name: str, default: Any = None) -> Any:\n \"\"\"Read an SDK object's attribute or a dict key without assuming a shape.\"\"\"\n if isinstance(value, dict):\n return value.get(name, default)\n return getattr(value, name, default)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n supports_reasoning_content: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n self._supports_reasoning_content: bool = supports_reasoning_content\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _build_thinking_kwargs(self, *, model: str, thinking: bool) -> dict[str, Any]:\n \"\"\"Return provider-specific thinking controls for OpenAI-compatible APIs.\"\"\"\n if not is_deepseek_model(model):\n return {}\n\n thinking_type = \"enabled\" if thinking else \"disabled\"\n logger.debug(\n \"DeepSeek thinking mode controls selected\",\n extra={\"model\": model, \"thinking_type\": thinking_type},\n )\n return {\n \"reasoning_effort\": \"high\",\n \"extra_body\": {\"thinking\": {\"type\": thinking_type}},\n }\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n current_thinking_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n **self._build_thinking_kwargs(model=candidate_model, thinking=thinking),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n reasoning_content = _get_attr_or_item(delta, \"reasoning_content\")\n if reasoning_content:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n if not current_thinking_started:\n yield StreamEvent(type=\"thinking_start\")\n current_thinking_started = True\n yield StreamEvent(type=\"thinking_delta\", text=reasoning_content)\n\n # Text content\n if delta.content:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_thinking_started:\n yield StreamEvent(type=\"thinking_stop\")\n current_thinking_started = False\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n msg_dict = {\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n }\n reasoning_content = _as_text(msg.get(\"reasoning_content\", \"\"))\n if self._supports_reasoning_content and reasoning_content:\n msg_dict[\"reasoning_content\"] = reasoning_content\n result.append(msg_dict)\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n thinking_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"thinking\":\n thinking_text = _as_text(block.get(\"thinking\", block.get(\"text\", \"\")))\n if thinking_text:\n thinking_parts.append(thinking_text)\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if self._supports_reasoning_content and thinking_parts:\n msg_dict[\"reasoning_content\"] = \"\".join(thinking_parts)\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n reasoning_content = _get_attr_or_item(message, \"reasoning_content\")\n if reasoning_content:\n blocks.append(ContentBlock(type=\"thinking\", thinking=reasoning_content))\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n supports_reasoning_content=provider == \"deepseek\",\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "1bc161a9c1ab5dc94286f19e0932168117870680": {"t": 1785766552.0611768, "n": 32740, "h": "b112a2d08fe35260", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nDEEPSEEK_CONTEXT_WINDOW_TOKENS: int = 1_000_000\nDEEPSEEK_MAX_OUTPUT_TOKENS: int = 384 * 1024\nDEEPSEEK_MODEL_PREFIXES: tuple[str, ...] = (\n \"deepseek-v4\",\n \"deepseek-chat\",\n \"deepseek-reasoner\",\n)\n\n\ndef is_deepseek_model(model: str) -> bool:\n \"\"\"Return whether a model name targets DeepSeek's OpenAI-compatible API.\"\"\"\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n return normalized_model.startswith(DEEPSEEK_MODEL_PREFIXES)\n\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map and clamp token-limit parameters based on model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n requested_token_limit: int = max_tokens\n token_limit: int = max_tokens\n if is_deepseek_model(model) and max_tokens > DEEPSEEK_MAX_OUTPUT_TOKENS:\n token_limit = DEEPSEEK_MAX_OUTPUT_TOKENS\n logger.info(\n \"Clamping DeepSeek max_tokens to provider output limit\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"requested_token_limit\": requested_token_limit,\n \"token_limit\": token_limit,\n },\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": token_limit,\n \"requested_token_limit\": requested_token_limit,\n },\n )\n return {token_param_name: token_limit}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "210079b4e0e55bef5ff586b3917d30caac755bc3": {"t": 1785766553.413567, "n": 31679, "h": "56e12e32c90ae6d1", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-v4-pro\", \"cheap\": \"deepseek-v4-pro\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "4ded648c1c1683b0b0d8cc286fbd7f456e59c74e": {"t": 1785766554.9736092, "n": 31673, "h": "e02c2e5815cb3dd4", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\", \"deepseek\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n \"deepseek\": \"https://api.deepseek.com\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n \"deepseek\": {\"primary\": \"deepseek-4.7\", \"cheap\": \"deepseek-4.7\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n \"deepseek\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\", \"deepseek\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "2ff0903fc81fa78b82caab987419e1ad36cc3d31": {"t": 1785766556.5643494, "n": 31519, "h": "009e3cb3f34ddb72", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n_PROVIDER_ALIASES: dict[str, str] = {\n \"alibaba\": \"qwen\",\n}\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n provider: str = _PROVIDER_ALIASES.get(config.provider, config.provider)\n\n if provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(provider)\n supports_docs: bool = provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "a8207f76e5ddd6c234100ca5f349eee71479d9f9": {"t": 1785766558.294539, "n": 31421, "h": "35f11a0a7c99d380", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3.6-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "c2114497dc028f7d4721856772458595280c1f6c": {"t": 1785766560.695445, "n": 31425, "h": "83403d71d5215e23", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3-coder-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.extend([\"gpt-5-mini\", \"gpt-5\"])\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "f69425b9de568c3e0ca81b555450ca1a9f32039f": {"t": 1785766562.4092991, "n": 31409, "h": "48705936b9824d39", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3-coder-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5\")\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "ce60fa56c45edffd3fff60da70c102356b5600fe": {"t": 1785766564.2036078, "n": 31385, "h": "a59b30275eee0099", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3-max\", \"cheap\": \"qwen-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5\")\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.extend([\"gpt-5.5-mini\", \"gpt-5\"])\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "4947c178dbbe5cd8fdc2e577999e284ddacba3d4": {"t": 1785766566.2598755, "n": 31414, "h": "ba47698fa28b08b4", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3-coder-plus\", \"cheap\": \"qwen3-30b-a3b-instruct-2507\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5.5-nano\")\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.append(\"gpt-5\")\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "9f94c14be6e26427deacdee35bfc1fe0b18908c4": {"t": 1785766568.1324322, "n": 31390, "h": "82dd4240c289cae9", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3-max\", \"cheap\": \"qwen-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5.5-nano\")\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.append(\"gpt-5\")\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "9f70c26ff25bfadee9143f5a37040ce1dac42f6d": {"t": 1785766569.5838544, "n": 31226, "h": "497b99fd1a5e9216", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover four providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"gpt5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith((\"gpt-5\", \"gpt5\")):\n return []\n\n canonical_base_model: str = base_model.replace(\"gpt5\", \"gpt-5\", 1) if base_model.startswith(\"gpt5\") else base_model\n\n variants: list[str] = []\n if canonical_base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif canonical_base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5.5-nano\")\n elif canonical_base_model == \"gpt-5.5-nano\":\n variants.append(\"gpt-5\")\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "9b078c694b15872e1080540f2c7f2ada4b6940e1": {"t": 1785766571.3224642, "n": 31240, "h": "a29e8abd3194646e", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3-max\", \"cheap\": \"qwen-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5.5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith(\"gpt-5\"):\n return []\n\n variants: list[str] = []\n if base_model == \"gpt-5\":\n variants.extend([\"gpt-5.5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5.5-nano\")\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "d0de3cb39932589fcff6e01fc20a40576f8ca9f9": {"t": 1785766572.7188165, "n": 31076, "h": "e57ba83bdf5bc6a2", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover four providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5.5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith(\"gpt-5\"):\n return []\n\n variants: list[str] = []\n if base_model == \"gpt-5\":\n variants.extend([\"gpt-5.5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5.5-nano\")\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "68d5c32f50a1cb1f0e45845184cbf835979571a2": {"t": 1785766574.3031929, "n": 30969, "h": "1615ea3860d0db98", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover four providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5.5\", \"cheap\": \"gpt-5.5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5.5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5.5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith(\"gpt-5.5\"):\n return []\n\n variants: list[str] = []\n if base_model == \"gpt-5.5\":\n variants.extend([\"gpt-5\", \"gpt-5.5-mini\", \"gpt-5.5-nano\"])\n elif base_model == \"gpt-5.5-mini\":\n variants.append(\"gpt-5.5-nano\")\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "61920deb7fbd930fb88c80f5ecdf73ae381935b0": {"t": 1785766575.7964451, "n": 30940, "h": "3f2390431221885d", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover four providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import APIStatusError as OpenAIAPIStatusError, AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5\", \"cheap\": \"gpt-5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n def _openai_not_found_fallback_models(self, model: str) -> list[str]:\n \"\"\"Return same-family model candidates when a model is not found.\"\"\"\n normalized_model: str = model.strip().lower()\n prefix: str = \"\"\n base_model: str = normalized_model\n if \"/\" in normalized_model:\n prefix, base_model = normalized_model.split(\"/\", 1)\n prefix = f\"{prefix}/\"\n\n if not base_model.startswith(\"gpt-5\"):\n return []\n\n variants: list[str] = []\n if base_model == \"gpt-5\":\n variants.extend([\"gpt-5-mini\", \"gpt-5-nano\"])\n elif base_model == \"gpt-5-mini\":\n variants.append(\"gpt-5-nano\")\n\n fallback_models: list[str] = [f\"{prefix}{variant}\" for variant in variants if variant != base_model]\n if fallback_models:\n logger.warning(\n \"[OpenAIAdapter] Model fallback engaged after 404 not_found: requested_model=%s fallback_candidates=%s\",\n model,\n fallback_models,\n )\n return fallback_models\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"messages\": api_messages,\n \"stream\": True,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n candidate_kwargs: dict[str, Any] = {\n **api_kwargs,\n \"model\": candidate_model,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n }\n try:\n stream = await self._client.chat.completions.create(**candidate_kwargs)\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Stream call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Stream model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if stream is None:\n raise RuntimeError(\"OpenAI stream initialization failed without a response stream\")\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response: Any = None\n model_candidates: list[str] = [model, *self._openai_not_found_fallback_models(model)]\n for idx, candidate_model in enumerate(model_candidates):\n try:\n response = await self._client.chat.completions.create(\n model=candidate_model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=candidate_model, max_tokens=max_tokens),\n )\n if idx > 0:\n logger.info(\n \"[OpenAIAdapter] Complete call succeeded with fallback model=%s requested_model=%s\",\n candidate_model,\n model,\n )\n break\n except OpenAIAPIStatusError as exc:\n if exc.status_code == 404 and idx < len(model_candidates) - 1:\n logger.warning(\n \"[OpenAIAdapter] Complete model unavailable (404), retrying fallback. requested_model=%s candidate_model=%s fallback_index=%d/%d\",\n model,\n candidate_model,\n idx + 1,\n len(model_candidates),\n )\n continue\n raise\n\n if response is None:\n raise RuntimeError(\"OpenAI complete call failed without a response\")\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "586abb48517738fe74c8902cfa9885706fb105f4": {"t": 1785766577.3026648, "n": 27509, "h": "9b3da4b47e51bd6f", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover five providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini/Qwen (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\", \"qwen\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n \"qwen\": \"https://dashscope-intl.aliyuncs.com/compatible-mode/v1\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5\", \"cheap\": \"gpt-5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n \"qwen\": {\"primary\": \"qwen3-max\", \"cheap\": \"qwen-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"messages\": api_messages,\n \"stream\": True,\n }\n api_kwargs.update(self._build_token_limit_kwargs(model=model, max_tokens=max_tokens))\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream = await self._client.chat.completions.create(**api_kwargs)\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response = await self._client.chat.completions.create(\n model=model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=model, max_tokens=max_tokens),\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n \"qwen\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\", \"qwen\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}, "2fe33cac24de15528b92a5ce81e67b3cc2bfd5f2": {"t": 1785766578.9462705, "n": 27345, "h": "edcb7a6187c4e2ea", "c": "\"\"\"\nProvider-agnostic LLM adapter layer.\n\nTwo adapter implementations cover four providers:\n- AnthropicAdapter: Anthropic (native) + MiniMax (base_url override)\n- OpenAIAdapter: OpenAI (native) + Gemini (base_url override)\n\nBoth yield a common StreamEvent protocol so the orchestrator and services\nare decoupled from vendor-specific SDK details.\n\"\"\"\n\nfrom __future__ import annotations\n\nimport json\nimport logging\nfrom dataclasses import dataclass, field\nfrom typing import Any, AsyncIterator, Literal, Protocol\n\nfrom anthropic import APIStatusError as AnthropicAPIStatusError, AsyncAnthropic\nfrom openai import AsyncOpenAI\n\nlogger = logging.getLogger(__name__)\n\n# ---------------------------------------------------------------------------\n# Common types\n# ---------------------------------------------------------------------------\n\nLLMProvider = Literal[\"anthropic\", \"minimax\", \"openai\", \"gemini\"]\n\nPROVIDER_BASE_URLS: dict[str, str] = {\n \"minimax\": \"https://api.minimax.io/anthropic\",\n \"gemini\": \"https://generativelanguage.googleapis.com/v1beta/openai/\",\n}\n\nPROVIDER_DEFAULT_MODELS: dict[str, dict[str, str]] = {\n \"anthropic\": {\"primary\": \"claude-opus-4-6\", \"cheap\": \"claude-haiku-4-5-20251001\"},\n \"minimax\": {\"primary\": \"MiniMax-M2.7\", \"cheap\": \"MiniMax-M2.7-highspeed\"},\n \"openai\": {\"primary\": \"gpt-5\", \"cheap\": \"gpt-5-mini\"},\n \"gemini\": {\"primary\": \"gemini-2.5-pro\", \"cheap\": \"gemini-2.5-flash\"},\n}\n\n\n@dataclass(frozen=True)\nclass LLMConfig:\n \"\"\"Resolved LLM configuration for a request.\"\"\"\n\n provider: LLMProvider\n primary_model: str\n cheap_model: str\n workflow_model: str\n api_key: str\n base_url: str | None = None\n\n\n@dataclass\nclass StreamEvent:\n \"\"\"Common stream event emitted by all adapters.\"\"\"\n\n type: Literal[\n \"thinking_start\",\n \"thinking_delta\",\n \"thinking_stop\",\n \"text_start\",\n \"text_delta\",\n \"text_stop\",\n \"tool_use_start\",\n \"tool_input_delta\",\n \"tool_use_stop\",\n \"usage\",\n ]\n text: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input_json: str | None = None\n input_tokens: int | None = None\n output_tokens: int | None = None\n\n\n@dataclass\nclass ContentBlock:\n \"\"\"Provider-agnostic content block (for completed messages).\"\"\"\n\n type: Literal[\"thinking\", \"text\", \"tool_use\"]\n text: str | None = None\n thinking: str | None = None\n signature: str | None = None\n tool_id: str | None = None\n tool_name: str | None = None\n tool_input: dict[str, Any] | None = None\n\n\n@dataclass\nclass CompletedMessage:\n \"\"\"Result of a non-streaming LLM call.\"\"\"\n\n content_blocks: list[ContentBlock]\n input_tokens: int = 0\n output_tokens: int = 0\n\n\n@dataclass\nclass ToolDef:\n \"\"\"Provider-agnostic tool definition.\"\"\"\n\n name: str\n description: str\n input_schema: dict[str, Any]\n\n\ndef _downgrade_document_blocks_for_messages(\n messages: list[dict[str, Any]],\n) -> list[dict[str, Any]]:\n \"\"\"Replace user ``document`` blocks with provider-safe text blocks.\"\"\"\n return [_downgrade_document_blocks_in_message(msg) for msg in messages]\n\n\ndef _downgrade_document_blocks_in_message(msg: dict[str, Any]) -> dict[str, Any]:\n \"\"\"Replace ``document`` content blocks with extracted-text blocks.\n\n Only user messages can contain multimodal content blocks, so\n assistant/tool messages are returned unchanged.\n \"\"\"\n content: Any = msg.get(\"content\")\n if msg.get(\"role\") != \"user\" or not isinstance(content, list):\n return msg\n\n needs_rewrite: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"document\" for b in content\n )\n if not needs_rewrite:\n return msg\n\n from services.file_handler import StoredFile, pdf_to_text_block\n import base64 as _b64\n\n new_blocks: list[dict[str, Any]] = []\n pdf_counter: int = 0\n for block in content:\n if not isinstance(block, dict) or block.get(\"type\") != \"document\":\n new_blocks.append(block)\n continue\n\n source: dict[str, Any] = block.get(\"source\", {})\n if source.get(\"media_type\") != \"application/pdf\":\n new_blocks.append(block)\n continue\n\n pdf_counter += 1\n filename: str = block.get(\"title\") or f\"attachment-{pdf_counter}.pdf\"\n\n try:\n raw_data: bytes = _b64.standard_b64decode(source.get(\"data\", \"\"))\n except Exception as exc:\n logger.warning(\"Failed to decode PDF base64 for %s: %s\", filename, exc)\n new_blocks.append({\n \"type\": \"text\",\n \"text\": f\"[Attached PDF '{filename}' could not be decoded]\",\n })\n continue\n\n sf = StoredFile(\n upload_id=\"\",\n filename=filename,\n mime_type=\"application/pdf\",\n size=len(raw_data),\n data=raw_data,\n )\n new_blocks.append(pdf_to_text_block(sf))\n\n logger.info(\n \"[LLMAdapter] Downgraded document blocks for user message (original_blocks=%d downgraded_blocks=%d)\",\n len(content),\n len(new_blocks),\n )\n return {**msg, \"content\": new_blocks}\n\n\n# ---------------------------------------------------------------------------\n# Protocol\n# ---------------------------------------------------------------------------\n\n\nclass LLMAdapter(Protocol):\n \"\"\"Protocol that all provider adapters implement.\"\"\"\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]: ...\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage: ...\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]: ...\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]: ...\n\n def build_completed_content(\n self, raw_response: Any\n ) -> list[ContentBlock]: ...\n\n\n# ---------------------------------------------------------------------------\n# Anthropic adapter (covers Anthropic + MiniMax)\n# ---------------------------------------------------------------------------\n\n\nclass AnthropicAdapter:\n \"\"\"Adapter for Anthropic Messages API (also used by MiniMax via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = True,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncAnthropic = AsyncAnthropic(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"max_tokens\": max_tokens,\n \"system\": system,\n \"messages\": api_messages,\n }\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n if thinking:\n api_kwargs[\"thinking\"] = {\"type\": \"adaptive\"}\n\n self._current_block_type = \"text\"\n async with self._client.messages.stream(**api_kwargs) as stream:\n async for event in stream:\n for se in self._translate_event(event):\n yield se\n\n final = await stream.get_final_message()\n if final and hasattr(final, \"usage\"):\n yield StreamEvent(\n type=\"usage\",\n input_tokens=final.usage.input_tokens,\n output_tokens=final.usage.output_tokens,\n )\n\n _current_block_type: str = \"text\"\n\n def _translate_event(self, event: Any) -> list[StreamEvent]:\n \"\"\"Translate a single Anthropic stream event into common StreamEvents.\"\"\"\n results: list[StreamEvent] = []\n\n if event.type == \"content_block_start\":\n block = event.content_block\n self._current_block_type = block.type\n if block.type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_start\"))\n elif block.type == \"text\":\n results.append(StreamEvent(type=\"text_start\"))\n elif block.type == \"tool_use\":\n results.append(\n StreamEvent(\n type=\"tool_use_start\",\n tool_id=block.id,\n tool_name=block.name,\n )\n )\n\n elif event.type == \"content_block_delta\":\n delta = event.delta\n if delta.type == \"thinking_delta\":\n results.append(StreamEvent(type=\"thinking_delta\", text=delta.thinking))\n elif delta.type == \"signature_delta\":\n pass\n elif delta.type == \"text_delta\":\n results.append(StreamEvent(type=\"text_delta\", text=delta.text))\n elif delta.type == \"input_json_delta\":\n results.append(\n StreamEvent(type=\"tool_input_delta\", tool_input_json=delta.partial_json)\n )\n\n elif event.type == \"content_block_stop\":\n if self._current_block_type == \"tool_use\":\n results.append(StreamEvent(type=\"tool_use_stop\"))\n elif self._current_block_type == \"thinking\":\n results.append(StreamEvent(type=\"thinking_stop\"))\n else:\n results.append(StreamEvent(type=\"text_stop\"))\n\n return results\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = self.format_messages_for_api(messages)\n response = await self._client.messages.create(\n model=model,\n max_tokens=max_tokens,\n system=system,\n messages=api_messages,\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=response.usage.input_tokens if response.usage else 0,\n output_tokens=response.usage.output_tokens if response.usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\"name\": t.name, \"description\": t.description, \"input_schema\": t.input_schema}\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n if self._supports_document_blocks:\n return messages\n return _downgrade_document_blocks_for_messages(messages)\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n for block in raw_response.content:\n if block.type == \"thinking\":\n blocks.append(\n ContentBlock(\n type=\"thinking\",\n thinking=block.thinking,\n signature=block.signature,\n )\n )\n elif block.type == \"text\":\n blocks.append(ContentBlock(type=\"text\", text=block.text))\n elif block.type == \"tool_use\":\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=block.id,\n tool_name=block.name,\n tool_input=block.input,\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# OpenAI adapter (covers OpenAI + Gemini)\n# ---------------------------------------------------------------------------\n\n\nclass OpenAIAdapter:\n \"\"\"Adapter for OpenAI Chat Completions API (also used by Gemini via base_url).\"\"\"\n\n def __init__(\n self,\n *,\n api_key: str,\n base_url: str | None = None,\n supports_document_blocks: bool = False,\n ) -> None:\n kwargs: dict[str, Any] = {\"api_key\": api_key}\n if base_url is not None:\n kwargs[\"base_url\"] = base_url\n self._client: AsyncOpenAI = AsyncOpenAI(**kwargs)\n self._supports_document_blocks: bool = supports_document_blocks\n\n def _build_token_limit_kwargs(self, *, model: str, max_tokens: int) -> dict[str, int]:\n \"\"\"Map token limit parameter name based on OpenAI model requirements.\"\"\"\n # Newer reasoning families (e.g. gpt-5 / o-series) reject `max_tokens`.\n normalized_model: str = model.strip().lower().split(\"/\")[-1]\n uses_completion_tokens: bool = normalized_model.startswith((\"gpt-5\", \"o\"))\n token_param_name: str = (\n \"max_completion_tokens\" if uses_completion_tokens else \"max_tokens\"\n )\n logger.debug(\n \"OpenAI token limit param selected\",\n extra={\n \"model\": model,\n \"normalized_model\": normalized_model,\n \"token_param_name\": token_param_name,\n \"token_limit\": max_tokens,\n },\n )\n return {token_param_name: max_tokens}\n\n # -- streaming ----------------------------------------------------------\n\n async def stream(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n tools: list[ToolDef] | None = None,\n thinking: bool = False,\n max_tokens: int = 32768,\n ) -> AsyncIterator[StreamEvent]:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n api_kwargs: dict[str, Any] = {\n \"model\": model,\n \"messages\": api_messages,\n \"stream\": True,\n }\n api_kwargs.update(self._build_token_limit_kwargs(model=model, max_tokens=max_tokens))\n if tools:\n api_kwargs[\"tools\"] = self.format_tools(tools)\n\n tool_calls_accum: dict[int, dict[str, str]] = {}\n current_text_started: bool = False\n\n stream = await self._client.chat.completions.create(**api_kwargs)\n chunk: Any | None = None\n async for chunk in stream:\n choice = chunk.choices[0] if chunk.choices else None\n if choice is None:\n continue\n\n delta = choice.delta\n if delta is None:\n continue\n\n # Text content\n if delta.content:\n if not current_text_started:\n yield StreamEvent(type=\"text_start\")\n current_text_started = True\n yield StreamEvent(type=\"text_delta\", text=delta.content)\n\n # Tool calls\n if delta.tool_calls:\n for tc_delta in delta.tool_calls:\n idx: int = tc_delta.index if tc_delta.index is not None else 0\n if idx not in tool_calls_accum:\n tool_calls_accum[idx] = {\n \"id\": tc_delta.id or f\"call_{idx}\",\n \"name\": \"\",\n \"arguments\": \"\",\n }\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n current_text_started = False\n\n if tc_delta.id:\n tool_calls_accum[idx][\"id\"] = tc_delta.id\n if tc_delta.function:\n if tc_delta.function.name:\n tool_calls_accum[idx][\"name\"] = tc_delta.function.name\n yield StreamEvent(\n type=\"tool_use_start\",\n tool_id=tool_calls_accum[idx][\"id\"],\n tool_name=tc_delta.function.name,\n )\n if tc_delta.function.arguments:\n tool_calls_accum[idx][\"arguments\"] += tc_delta.function.arguments\n yield StreamEvent(\n type=\"tool_input_delta\",\n tool_input_json=tc_delta.function.arguments,\n )\n\n # Finish\n if choice.finish_reason:\n if current_text_started:\n yield StreamEvent(type=\"text_stop\")\n for tc_data in tool_calls_accum.values():\n yield StreamEvent(\n type=\"tool_use_stop\",\n tool_id=tc_data[\"id\"],\n tool_name=tc_data[\"name\"],\n )\n\n # Usage (from last chunk if available)\n if chunk and hasattr(chunk, \"usage\") and chunk.usage:\n yield StreamEvent(\n type=\"usage\",\n input_tokens=chunk.usage.prompt_tokens,\n output_tokens=chunk.usage.completion_tokens,\n )\n\n # -- non-streaming (simple calls) --------------------------------------\n\n async def complete(\n self,\n *,\n model: str,\n system: str,\n messages: list[dict[str, Any]],\n max_tokens: int = 1024,\n ) -> CompletedMessage:\n api_messages: list[dict[str, Any]] = [\n {\"role\": \"system\", \"content\": system}\n ] + self.format_messages_for_api(messages)\n\n response = await self._client.chat.completions.create(\n model=model,\n messages=api_messages,\n **self._build_token_limit_kwargs(model=model, max_tokens=max_tokens),\n )\n blocks: list[ContentBlock] = self.build_completed_content(response)\n usage = response.usage\n return CompletedMessage(\n content_blocks=blocks,\n input_tokens=usage.prompt_tokens if usage else 0,\n output_tokens=usage.completion_tokens if usage else 0,\n )\n\n # -- formatting helpers -------------------------------------------------\n\n def format_tools(self, tools: list[ToolDef]) -> list[dict[str, Any]]:\n return [\n {\n \"type\": \"function\",\n \"function\": {\n \"name\": t.name,\n \"description\": t.description,\n \"parameters\": t.input_schema,\n },\n }\n for t in tools\n ]\n\n def format_messages_for_api(\n self, messages: list[dict[str, Any]]\n ) -> list[dict[str, Any]]:\n \"\"\"Translate Anthropic-style stored messages to OpenAI chat format.\"\"\"\n if not self._supports_document_blocks:\n messages = _downgrade_document_blocks_for_messages(messages)\n\n def _as_text(value: Any) -> str:\n \"\"\"Coerce nullable/non-string content into API-safe text.\"\"\"\n if value is None:\n return \"\"\n if isinstance(value, str):\n return value\n if isinstance(value, (dict, list)):\n return json.dumps(value, ensure_ascii=False)\n return str(value)\n\n result: list[dict[str, Any]] = []\n for msg in messages:\n role: str = msg.get(\"role\", \"user\")\n content: Any = msg.get(\"content\", \"\")\n\n # Pass through already-openai tool messages without dropping tool_call_id.\n if role == \"tool\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": _as_text(msg.get(\"tool_call_id\", \"\")),\n \"content\": _as_text(content),\n })\n continue\n\n # Idempotency: preserve already-openai assistant tool_calls.\n if role == \"assistant\" and isinstance(content, str) and msg.get(\"tool_calls\"):\n normalized_tool_calls: list[dict[str, Any]] = []\n for tc in msg.get(\"tool_calls\", []):\n if not isinstance(tc, dict):\n continue\n function = tc.get(\"function\", {}) if isinstance(tc.get(\"function\"), dict) else {}\n raw_arguments = function.get(\"arguments\", \"\")\n arguments = (\n raw_arguments\n if isinstance(raw_arguments, str)\n else json.dumps(raw_arguments, ensure_ascii=False)\n )\n normalized_tool_calls.append({\n \"id\": _as_text(tc.get(\"id\", \"\")),\n \"type\": \"function\",\n \"function\": {\n \"name\": _as_text(function.get(\"name\", \"\")),\n \"arguments\": arguments,\n },\n })\n result.append({\n \"role\": \"assistant\",\n \"content\": _as_text(content),\n \"tool_calls\": normalized_tool_calls,\n })\n continue\n\n if role == \"user\" and isinstance(content, list):\n # Check for tool_result blocks (Anthropic format → OpenAI tool messages)\n has_tool_results: bool = any(\n isinstance(b, dict) and b.get(\"type\") == \"tool_result\"\n for b in content\n )\n if has_tool_results:\n for block in content:\n if isinstance(block, dict) and block.get(\"type\") == \"tool_result\":\n result.append({\n \"role\": \"tool\",\n \"tool_call_id\": block.get(\"tool_use_id\", \"\"),\n \"content\": _as_text(block.get(\"content\", \"\")),\n })\n continue\n\n # Image/text blocks — translate to OpenAI format\n openai_parts: list[dict[str, Any]] = []\n for block in content:\n if isinstance(block, dict):\n btype: str = block.get(\"type\", \"\")\n if btype == \"text\":\n openai_parts.append(\n {\"type\": \"text\", \"text\": _as_text(block.get(\"text\", \"\"))}\n )\n elif btype == \"image\":\n source = block.get(\"source\", {})\n media_type: str = source.get(\"media_type\", \"image/png\")\n data: str = source.get(\"data\", \"\")\n openai_parts.append({\n \"type\": \"image_url\",\n \"image_url\": {\"url\": f\"data:{media_type};base64,{data}\"},\n })\n elif isinstance(block, str):\n openai_parts.append({\"type\": \"text\", \"text\": block})\n result.append({\"role\": \"user\", \"content\": openai_parts})\n continue\n\n if role == \"assistant\" and isinstance(content, list):\n text_parts: list[str] = []\n tool_calls: list[dict[str, Any]] = []\n for block in content:\n if not isinstance(block, dict):\n continue\n btype = block.get(\"type\", \"\")\n if btype == \"text\":\n text_parts.append(_as_text(block.get(\"text\", \"\")))\n elif btype == \"tool_use\":\n tool_calls.append({\n \"id\": block.get(\"id\", \"\"),\n \"type\": \"function\",\n \"function\": {\n \"name\": block.get(\"name\", \"\"),\n \"arguments\": json.dumps(block.get(\"input\", {})),\n },\n })\n # Skip thinking blocks — OpenAI doesn't use them\n\n msg_dict: dict[str, Any] = {\n \"role\": \"assistant\",\n \"content\": \"\\n\".join(text_parts),\n }\n if tool_calls:\n msg_dict[\"tool_calls\"] = tool_calls\n result.append(msg_dict)\n continue\n\n # Simple string content\n result.append({\"role\": role, \"content\": _as_text(content)})\n return result\n\n def build_completed_content(self, raw_response: Any) -> list[ContentBlock]:\n blocks: list[ContentBlock] = []\n choice = raw_response.choices[0] if raw_response.choices else None\n if choice is None:\n return blocks\n\n message = choice.message\n if message.content:\n blocks.append(ContentBlock(type=\"text\", text=message.content))\n if message.tool_calls:\n for tc in message.tool_calls:\n blocks.append(\n ContentBlock(\n type=\"tool_use\",\n tool_id=tc.id,\n tool_name=tc.function.name,\n tool_input=json.loads(tc.function.arguments)\n if tc.function.arguments\n else {},\n )\n )\n return blocks\n\n\n# ---------------------------------------------------------------------------\n# Factory\n# ---------------------------------------------------------------------------\n\n\n_PROVIDERS_WITHOUT_DOCUMENT_BLOCKS: frozenset[str] = frozenset({\n \"minimax\",\n \"openai\",\n \"gemini\",\n})\n\n\ndef get_adapter(config: LLMConfig) -> AnthropicAdapter | OpenAIAdapter:\n \"\"\"Create the appropriate adapter for a resolved LLM config.\"\"\"\n if config.provider in (\"anthropic\", \"minimax\"):\n base_url: str | None = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return AnthropicAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n if config.provider in (\"openai\", \"gemini\"):\n base_url = config.base_url or PROVIDER_BASE_URLS.get(config.provider)\n supports_docs: bool = config.provider not in _PROVIDERS_WITHOUT_DOCUMENT_BLOCKS\n return OpenAIAdapter(\n api_key=config.api_key,\n base_url=base_url,\n supports_document_blocks=supports_docs,\n )\n\n raise ValueError(f\"Unsupported LLM provider: {config.provider}\")\n"}}}}