Files
wehub-resource-sync 542cfa195c
CI / Frontend build (push) Failing after 9m6s
CI / Plugin validate (push) Failing after 9m27s
CI / Python lint (push) Failing after 16m1s
CI / Tests (push) Successful in 18m0s
Deploy / deploy (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:27 +08:00

803 lines
34 KiB
Python
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.
"""LLM client and prompt building for SimpleQA evaluation.
Supports:
- Google Gemini (Vertex AI and standard API)
- OpenAI-compatible APIs (vLLM, etc.)
"""
import asyncio
import base64
import logging
import os
# Try to import Google GenAI for Gemini support
try:
import google.genai as genai
from google.genai.types import (
GenerateContentConfig,
Part,
Blob,
HttpOptions,
Content,
)
GEMINI_AVAILABLE = True
except ImportError:
GEMINI_AVAILABLE = False
genai = None
GenerateContentConfig = None
Part = None
Blob = None
HttpOptions = None
Content = None
from .retrieval import RetrievalResult
logger = logging.getLogger(__name__)
# System Prompts
SYSTEM_PROMPT_NAIVE = """You are a research assistant who answers questions.
Use <think></think> tags to show your reasoning if needed.
Answer the question directly and concisely.
"""
SYSTEM_PROMPT_EVIDENCE_QA = """You are a research assistant who answers questions based on provided evidence.
Use <think></think> tags to show your reasoning if needed.
Answer the question directly and concisely based ONLY on the provided evidence.
"""
SYSTEM_PROMPT_SCREENSHOT = SYSTEM_PROMPT_EVIDENCE_QA
SYSTEM_PROMPT_TEXT_RAG = SYSTEM_PROMPT_EVIDENCE_QA
SYSTEM_PROMPT_VECTOR = SYSTEM_PROMPT_EVIDENCE_QA
SYSTEM_PROMPT_SHORT_ANSWER = """Answer the question with as few words as possible. Give only the answer, no explanation.
"""
SYSTEM_PROMPT_REACT = """You are a research assistant who answers questions using a search tool.
You will be provided with retrieved Wikipedia screenshot tiles as evidence.
IMPORTANT: Try your best to answer with the evidence you have. Only search again if the evidence is clearly about a WRONG topic and does not contain the answer at all.
To search for different evidence, output ONLY: <search>your refined search query</search>
Otherwise, answer the question directly and concisely.
Rules:
- READ the evidence images carefully — the answer is often there even if not obvious.
- If the images show the relevant Wikipedia article, answer from them. Do NOT search again.
- Only use <search> if the retrieved tiles are about a completely unrelated topic.
- Do NOT repeat the same search query — use different keywords.
- Use <think></think> tags to show your reasoning if needed.
"""
SYSTEM_PROMPT_REACT_V2 = """You are a research assistant who answers questions using a search tool.
You will be provided with retrieved Wikipedia screenshot tiles as evidence.
You have two actions:
1. **Answer**: If you can find or infer the answer from the evidence, respond with your answer directly.
2. **Search**: If the evidence does NOT contain the answer, output: <search>new search query</search>
CRITICAL rules:
- ALWAYS try to answer first. Only search if the evidence is about the WRONG topic entirely.
- Each search query MUST use DIFFERENT keywords than all previous queries. Think about synonyms, related entities, or the answer's broader topic.
- If you've already searched 2+ times without finding the answer, make your BEST GUESS based on whatever partial evidence you have. Do not give up.
- Never output an empty answer. If unsure, state your best guess with a caveat.
- Use <think></think> tags for reasoning.
"""
SYSTEM_PROMPT_REACT_MULTIHOP = """You are a research assistant who answers multi-hop questions using a search tool.
You will be provided with retrieved Wikipedia screenshot tiles as evidence.
Multi-hop questions require information from MULTIPLE Wikipedia pages. For example:
- "Where did X's father die?" → First find who X's father is, then search for the father's death place.
- "Which film came out first, A or B?" → Search for film A's release date, then film B's release date.
Strategy:
1. Read the evidence carefully. Extract any INTERMEDIATE facts (names, dates, locations) that help answer the question.
2. If you found an intermediate fact but still need more info, search for the next entity: <search>entity name topic</search>
3. Only give your final answer when you have ALL the pieces needed.
Rules:
- For multi-hop questions, you will usually need 2-3 searches. This is EXPECTED — do not try to answer with just the first search.
- In <think> tags, ALWAYS record: the specific facts you found (names, dates, places) so you don't lose them.
- Extract specific entity names from evidence tiles to use as search queries.
- Each search query MUST use DIFFERENT keywords. Be specific: use full names, dates, or titles you found.
- When you have enough info, give a concise final answer.
"""
SYSTEM_PROMPT_PIXEL_QUERY = """You are a research assistant who answers questions based on retrieved visual evidence.
The first image contains the question you need to answer.
The remaining images are retrieved evidence that may contain the answer.
Read the question from the first image, then use the evidence images to answer it.
Use <think></think> tags to show your reasoning if needed.
Answer the question directly and concisely.
"""
SYSTEM_PROMPT_MULTIMODAL_QUERY = """You are a research assistant who answers questions based on retrieved visual evidence.
You will receive: (1) a text question, (2) a query image, and (3) retrieved Wikipedia evidence images.
Use the query image and evidence images to answer the question.
Use <think></think> tags to show your reasoning if needed.
Answer the question directly and concisely.
"""
def _build_fewshot_turns(demos: list[dict], encode_image_fn) -> list[dict]:
"""Build a list of (user, assistant) message turns for in-context few-shot.
Each demo becomes: user={Q text + demo image} → assistant={answer}. The
chat-tuned model treats these as prior conversation turns rather than
mixing them with the current question's evidence — this is the canonical
few-shot format for instruction-tuned chat models.
"""
turns: list[dict] = []
for demo in demos:
user_content: list[dict] = [
{"type": "text", "text": f"Question: {demo['question']}"},
]
img_path = demo.get("image_path")
if img_path and encode_image_fn and os.path.exists(img_path):
try:
b64 = encode_image_fn(img_path)
if b64:
user_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
}
)
except Exception as e:
logger.warning(f"Failed to encode few-shot image {img_path}: {e}")
turns.append({"role": "user", "content": user_content})
turns.append({"role": "assistant", "content": demo["answer"]})
return turns
def build_messages(
query: str,
retrieval_result: RetrievalResult,
encode_image_fn=None,
additional_instructions: str | None = None,
few_shot_demos: list[dict] | None = None,
) -> list[dict]:
"""Build messages for LLM based on retrieval result.
When ``retrieval_result.pixel_query_path`` is set the query is sent as an
image. Two modes:
- **Multimodal** (retrieval_type contains "multimodal"): text question + query image + retrieved tiles.
- **Pixel query** (rendered question as image): first image = question, then retrieved tiles.
"""
# ---- Multimodal / pixel-query mode: text + raw species/landmark photo + retrieved tiles ----
# query_image_path = raw species/landmark photo (for generation, always).
# pixel_query_path = rendered card or raw photo (for retrieval only; ignored here).
# Falls back to pixel_query_path if query_image_path is not set (backward compat).
gen_image_path = (
retrieval_result.query_image_path or retrieval_result.pixel_query_path
)
if gen_image_path and encode_image_fn:
system_prompt = SYSTEM_PROMPT_MULTIMODAL_QUERY
# Decide evidence_note based on what retrieval actually returned. Three cases:
# (a) retrieved images (screenshot retrieval) — evidence is image tiles after the query
# (b) retrieved text (text retrieval) — evidence is rendered as text after the query
# (c) no retrieval — query image only
# Until 2026-04-29 this branch silently dropped retrieval_result.text whenever the
# query image was set, turning every "EVQA + text retrieval" cell into an effective
# naive run. Fixed by adding the text-passages block alongside the multimodal preamble.
if retrieval_result.images:
evidence_note = "The first image is the query image. The following images are retrieved Wikipedia evidence. Answer the question based on the evidence."
elif retrieval_result.text:
evidence_note = "The image is the query image. Below is retrieved Wikipedia evidence (text). Answer the question based on the evidence and the image."
else:
evidence_note = "The first image is the query image. Answer the question based on the image (no additional evidence was retrieved)."
text_parts = [
f"Question: {query}",
"",
evidence_note,
]
if retrieval_result.text:
# Option 1: no URL header in multimodal branch either. Reader gets the
# chunks and the query image, no metadata leak.
text_parts.extend(
[
"",
retrieval_result.text,
]
)
if additional_instructions:
text_parts.append("")
text_parts.append(additional_instructions)
user_content: list[dict] = [
{"type": "text", "text": "\n".join(text_parts)},
]
# Add raw species/landmark photo
if os.path.exists(gen_image_path):
try:
img_base64 = encode_image_fn(gen_image_path)
if img_base64:
user_content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_base64}"},
}
)
except Exception as e:
logger.warning(f"Failed to encode query image {gen_image_path}: {e}")
user_content.append(
{"type": "text", "text": f"(Image unavailable) Query: {query}"}
)
else:
logger.warning(f"Query image not found: {gen_image_path}")
user_content.append({"type": "text", "text": f"Query: {query}"})
# Add retrieved tiles
if retrieval_result.images:
for img_path, score in retrieval_result.images:
if os.path.exists(img_path):
try:
img_base64 = encode_image_fn(img_path)
if img_base64:
user_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_base64}"
},
}
)
except Exception as e:
logger.warning(f"Failed to encode image {img_path}: {e}")
return [
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_content},
]
# ---- Original modes --------------------------------------------------
# Select system prompt based on retrieval type
if retrieval_result.base64_image:
system_prompt = SYSTEM_PROMPT_SCREENSHOT
user_content = [
{"type": "text", "text": query},
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{retrieval_result.base64_image}"
},
},
]
elif (
retrieval_result.retrieval_type == "text_api+rendered"
and retrieval_result.images
and encode_image_fn
):
# Text retrieval rendered as images. Mirror the text-RAG framing so
# evidence comes first and the reader sees an explicit "Question:"
# suffix — same structure as the text→text branch below, only the
# evidence modality differs.
system_prompt = SYSTEM_PROMPT_TEXT_RAG
user_content = []
for img_path, score in retrieval_result.images:
if os.path.exists(img_path):
try:
img_base64 = encode_image_fn(img_path)
if img_base64:
user_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_base64}"
},
}
)
except Exception as e:
logger.warning(f"Failed to encode image {img_path}: {e}")
user_content.append({"type": "text", "text": f"Question: {query}"})
elif retrieval_result.images and encode_image_fn:
system_prompt = SYSTEM_PROMPT_VECTOR
user_content = [{"type": "text", "text": query}]
# Encode and add retrieved images
for img_path, score in retrieval_result.images:
if os.path.exists(img_path):
try:
img_base64 = encode_image_fn(img_path)
if img_base64:
user_content.append(
{
"type": "image_url",
"image_url": {
"url": f"data:image/png;base64,{img_base64}"
},
}
)
except Exception as e:
logger.warning(f"Failed to encode image {img_path}: {e}")
elif retrieval_result.text:
system_prompt = SYSTEM_PROMPT_TEXT_RAG
# Option 1 (2026-04-29): no `Context from {urls}:` wrapper. URL leak gave
# text retrieval an unfair advantage on entity-answering tasks. Reader sees
# only the retrieved chunks and the question. URL still recorded in the
# JSONL via retrieval_result.source_url for logging/grading.
user_content = f"""{retrieval_result.text}
Question: {query}"""
else:
# Naive mode
system_prompt = SYSTEM_PROMPT_NAIVE
user_content = query
# Append additional instructions (e.g. short-answer prompt for EM-eval tasks)
if additional_instructions:
if isinstance(user_content, str):
user_content = user_content + "\n\n" + additional_instructions
else:
# list of content blocks — append as text
user_content.append({"type": "text", "text": additional_instructions})
# Few-shot as prior user/assistant turns (canonical chat few-shot format)
if few_shot_demos and encode_image_fn:
fewshot_turns = _build_fewshot_turns(few_shot_demos, encode_image_fn)
else:
fewshot_turns = []
return [
{"role": "system", "content": system_prompt},
*fewshot_turns,
{"role": "user", "content": user_content},
]
def _encode_images_to_content(
images: list[tuple[str, float]], encode_image_fn
) -> list[dict]:
"""Encode image paths to base64 content blocks."""
content = []
for img_path, score in images:
if os.path.exists(img_path):
try:
img_base64 = encode_image_fn(img_path)
if img_base64:
content.append(
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{img_base64}"},
}
)
except Exception as e:
logger.warning(f"Failed to encode image {img_path}: {e}")
return content
def build_react_messages(
query: str,
retrieval_results: list[RetrievalResult],
assistant_responses: list[str],
encode_image_fn=None,
prompt_version: str = "v1",
is_last_turn: bool = False,
previous_queries: list[str] | None = None,
) -> list[dict]:
"""Build multi-turn messages for ReAct retrieval loop.
Args:
query: Original question text.
retrieval_results: List of RetrievalResult from each round.
assistant_responses: List of assistant responses from previous rounds.
encode_image_fn: Function to encode images to base64.
prompt_version: "v1" (original) or "v2" (improved).
is_last_turn: If True, add force-answer instruction.
previous_queries: List of previous search queries (for v2, to avoid repetition).
Returns:
Messages list for the LLM.
"""
_prompt_map = {
"v1": SYSTEM_PROMPT_REACT,
"v2": SYSTEM_PROMPT_REACT_V2,
"multihop": SYSTEM_PROMPT_REACT_MULTIHOP,
}
system_prompt = _prompt_map.get(prompt_version, SYSTEM_PROMPT_REACT_V2)
messages = [{"role": "system", "content": system_prompt}]
for turn_idx, retrieval_result in enumerate(retrieval_results):
# Build user message with evidence images
if turn_idx == 0:
user_content: list[dict] = [
{
"type": "text",
"text": f"Question: {query}\n\nHere are retrieved Wikipedia evidence tiles:",
}
]
else:
text = "Here are new search results for your query:"
# Remind model of previous queries to avoid repetition (v2 and multihop)
if prompt_version in ("v2", "multihop") and previous_queries:
used = previous_queries[:turn_idx]
if used:
text += f"\n⚠️ You already searched: {used}. Do NOT repeat these. Use DIFFERENT keywords."
user_content = [{"type": "text", "text": text}]
if retrieval_result.images and encode_image_fn:
user_content.extend(
_encode_images_to_content(retrieval_result.images, encode_image_fn)
)
if not retrieval_result.has_content:
user_content.append(
{"type": "text", "text": "(No results found for this search.)"}
)
# On last turn, inject force-answer instruction
if is_last_turn and turn_idx == len(retrieval_results) - 1:
user_content.append(
{
"type": "text",
"text": (
"\n⚠️ This is your FINAL turn. You MUST provide an answer now — do NOT search again. "
"Give your best answer based on ALL evidence seen so far. If uncertain, make your best guess."
),
}
)
messages.append({"role": "user", "content": user_content})
# Add assistant response if we have one for this turn
if turn_idx < len(assistant_responses):
messages.append(
{"role": "assistant", "content": assistant_responses[turn_idx]}
)
return messages
class LLMClient:
"""Simplified async LLM client for Gemini using Vertex AI."""
def __init__(
self,
model: str,
api_base: str = "http://localhost:8000/v1",
api_key: str = "dummy",
temperature: float = 0.0,
max_tokens: int = 16384,
timeout: float = 120.0,
max_context_tokens: int | None = None,
enable_thinking: bool | None = None,
force_openai_compat: bool = False,
):
self.model = model
self.temperature = temperature
self.max_tokens = max_tokens
self.timeout = timeout
self.max_context_tokens = max_context_tokens
self.enable_thinking = enable_thinking
print(f"context length model: {max_context_tokens}")
# Gemini routes to Google GenAI SDK unless forced to OpenAI-compatible
# (aggregators like OpenRouter / Commonstack expose Gemini via OAI-compat).
self.is_gemini = ("gemini" in model.lower()) and not force_openai_compat
if self.is_gemini:
if not GEMINI_AVAILABLE:
raise ImportError(
"google-genai package is required for Gemini models. Install with: pip install google-genai"
)
# Use Vertex AI if GEMINI_API_KEY is set and GOOGLE_GENAI_USE_VERTEXAI is true
vertex_api_key = os.getenv("GEMINI_API_KEY")
use_vertex = os.getenv("GOOGLE_GENAI_USE_VERTEXAI", "").lower() == "true"
if vertex_api_key and use_vertex:
logger.info(f"Using Vertex AI for Gemini model: {model}")
# Ensure GOOGLE_API_KEY is not set when using Vertex AI (it causes conflicts)
if "GOOGLE_API_KEY" in os.environ:
logger.warning(
"GOOGLE_API_KEY is set but using Vertex AI. Unsetting GOOGLE_API_KEY to avoid conflicts."
)
del os.environ["GOOGLE_API_KEY"]
os.environ["GEMINI_API_KEY"] = vertex_api_key
os.environ["GOOGLE_GENAI_USE_VERTEXAI"] = "true"
self.gemini_client = genai.Client(
http_options=HttpOptions(api_version="v1")
)
else:
# Use standard Gemini API
logger.info(f"Using standard Gemini API for model: {model}")
api_key = api_key if api_key != "dummy" else os.getenv("GOOGLE_API_KEY")
if not api_key:
raise ValueError(
"GOOGLE_API_KEY or GEMINI_API_KEY environment variable is required for Gemini models"
)
self.gemini_client = genai.Client(api_key=api_key)
else:
# Use OpenAI-compatible API
from openai import AsyncOpenAI
logger.info(f"Using OpenAI-compatible API: {api_base}")
self.client = AsyncOpenAI(
api_key=api_key,
base_url=api_base,
timeout=timeout,
max_retries=0,
)
self.gemini_client = None
async def generate(
self, messages: list[dict], max_retries: int = 3, connection_retries: int = 12
) -> tuple[str, dict]:
"""Generate response from messages with retry on timeout/connection errors.
Args:
max_retries: Retry count for timeout errors.
connection_retries: Retry count for connection errors (server restart).
12 retries × 10s = ~2 min window for server to come back.
Returns:
Tuple of (generated_text, usage_dict).
"""
# Check and truncate if needed
if hasattr(self, "max_context_tokens") and self.max_context_tokens:
estimated_tokens = self._estimate_tokens(messages)
if estimated_tokens > self.max_context_tokens - self.max_tokens:
logger.warning(
f"Estimated {estimated_tokens} tokens exceeds limit, truncating..."
)
messages = self._truncate_messages(messages, self.max_context_tokens)
conn_attempts = 0
timeout_attempts = 0
while True:
try:
if self.is_gemini:
return await self._generate_gemini(messages)
else:
return await self._generate_openai(messages)
except asyncio.TimeoutError:
timeout_attempts += 1
if timeout_attempts >= max_retries:
raise
wait_time = 2**timeout_attempts # 2, 4, 8 seconds
logger.warning(
f"Timeout on attempt {timeout_attempts}/{max_retries}, retrying in {wait_time}s..."
)
await asyncio.sleep(wait_time)
except Exception as e:
error_str = str(e).lower()
if "timeout" in error_str or "timed out" in error_str:
timeout_attempts += 1
if timeout_attempts >= max_retries:
raise
wait_time = 2**timeout_attempts
logger.warning(
f"Timeout on attempt {timeout_attempts}/{max_retries}, retrying in {wait_time}s..."
)
await asyncio.sleep(wait_time)
elif "connection" in error_str or "connect" in error_str:
conn_attempts += 1
if conn_attempts >= connection_retries:
raise
wait_time = 10 # fixed 10s — server restart takes ~30-60s
logger.warning(
f"Connection error ({conn_attempts}/{connection_retries}), retrying in {wait_time}s..."
)
await asyncio.sleep(wait_time)
elif (
"429" in error_str
or "rate_limit" in error_str
or "rate limit" in error_str
):
# Provider rate limit — exponential backoff with jitter
timeout_attempts += 1
if timeout_attempts >= max_retries + 3: # extra patience for 429
raise
import random
wait_time = min(60, 5 * (2**timeout_attempts)) + random.uniform(
0, 3
)
logger.warning(
f"429 rate-limit (attempt {timeout_attempts}), backing off {wait_time:.1f}s..."
)
await asyncio.sleep(wait_time)
else:
raise
async def _generate_gemini(self, messages: list[dict]) -> tuple[str, dict]:
"""Generate using Gemini API."""
# Extract system prompt and user content
system_prompt = None
user_content = None
for msg in messages:
if msg.get("role") == "system":
system_prompt = msg.get("content", "")
elif msg.get("role") == "user":
user_content = msg.get("content", "")
# Build parts for Gemini
parts = []
# Add system prompt to the beginning of user message if present
if system_prompt:
parts.append(Part(text=f"{system_prompt}\n\n"))
# Process user content
if isinstance(user_content, str):
# Simple text
if parts:
parts[0] = Part(text=parts[0].text + user_content)
else:
parts.append(Part(text=user_content))
elif isinstance(user_content, list):
# Multi-modal content
for item in user_content:
if item.get("type") == "text":
text = item.get("text", "")
if (
parts
and isinstance(parts[0], Part)
and hasattr(parts[0], "text")
):
# Append to existing text part
parts[0] = Part(text=parts[0].text + text)
else:
parts.append(Part(text=text))
elif item.get("type") == "image_url":
# Extract base64 image
image_url = item.get("image_url", {}).get("url", "")
if image_url.startswith("data:image"):
try:
header, data = image_url.split(",", 1)
mime_type = header.split(";")[0].split(":")[1]
image_bytes = base64.b64decode(data)
parts.append(
Part(
inline_data=Blob(
mime_type=mime_type, data=image_bytes
)
)
)
except Exception as e:
logger.error(f"Failed to process image: {e}")
raise
# Create content
content = Content(role="user", parts=parts)
# Call API in executor to avoid blocking
loop = asyncio.get_event_loop()
def _call_api():
try:
response = self.gemini_client.models.generate_content(
model=self.model,
contents=[content],
config=GenerateContentConfig(
temperature=self.temperature, max_output_tokens=self.max_tokens
),
)
return response
except Exception as e:
logger.error(f"Gemini API error: {e}")
raise
response = await loop.run_in_executor(None, _call_api)
# Extract text
text = response.text if hasattr(response, "text") and response.text else ""
# Extract usage
usage = {}
if hasattr(response, "usage_metadata") and response.usage_metadata:
usage_meta = response.usage_metadata
usage = {
"prompt_tokens": getattr(usage_meta, "prompt_token_count", 0),
"completion_tokens": getattr(usage_meta, "candidates_token_count", 0),
"total_tokens": getattr(usage_meta, "total_token_count", 0),
}
return text, usage
async def _generate_openai(self, messages: list[dict]) -> tuple[str, dict]:
"""Generate using OpenAI-compatible API."""
kwargs = dict(
model=self.model,
messages=messages,
max_tokens=self.max_tokens,
timeout=self.timeout,
)
# Some modern reasoning models deprecate `temperature` (Claude Opus 4.7+, some GPT-5 variants).
# Only send it when we actually want to override the default.
model_lower = self.model.lower()
drops_temperature = any(
x in model_lower for x in ("opus-4-7", "opus-4-8", "gpt-5.4-pro")
)
if not drops_temperature:
kwargs["temperature"] = self.temperature
if self.enable_thinking is not None:
kwargs["extra_body"] = {
"chat_template_kwargs": {"enable_thinking": self.enable_thinking}
}
response = await self.client.chat.completions.create(**kwargs)
generated_text = response.choices[0].message.content
usage = {}
if response.usage:
usage = {
"prompt_tokens": response.usage.prompt_tokens,
"completion_tokens": response.usage.completion_tokens,
"total_tokens": response.usage.total_tokens,
}
return generated_text, usage
def _estimate_tokens(self, messages: list[dict]) -> int:
"""Estimate token count from messages (rough: ~4 chars per token)."""
total_chars = 0
for msg in messages:
content = msg.get("content", "")
if isinstance(content, str):
total_chars += len(content)
elif isinstance(content, list):
for item in content:
if isinstance(item, dict):
if item.get("type") == "text":
total_chars += len(item.get("text", ""))
elif item.get("type") == "image_url":
# Rough estimate for image tokens
total_chars += 1000 * 4 # ~1000 tokens per image
return total_chars // 4
def _truncate_messages(self, messages: list[dict], max_tokens: int) -> list[dict]:
"""Truncate text content in messages to fit within token limit."""
# Reserve tokens for response
available_tokens = max_tokens - self.max_tokens - 500 # buffer
max_chars = available_tokens * 4
truncated = []
total_chars = 0
for msg in messages:
new_msg = msg.copy()
content = msg.get("content", "")
if isinstance(content, str):
if total_chars + len(content) > max_chars:
remaining = max(0, max_chars - total_chars)
new_msg["content"] = (
content[:remaining]
+ "\n\n[Content truncated due to context limit]"
)
logger.warning(
f"Truncated message content from {len(content)} to {remaining} chars"
)
total_chars += len(new_msg["content"])
elif isinstance(content, list):
new_content = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
text = item.get("text", "")
if total_chars + len(text) > max_chars:
remaining = max(0, max_chars - total_chars)
new_item = item.copy()
new_item["text"] = (
text[:remaining]
+ "\n\n[Content truncated due to context limit]"
)
new_content.append(new_item)
logger.warning(
f"Truncated text content from {len(text)} to {remaining} chars"
)
total_chars += remaining
else:
new_content.append(item)
total_chars += len(text)
else:
new_content.append(item)
if isinstance(item, dict) and item.get("type") == "image_url":
total_chars += 1000 * 4 # image token estimate
new_msg["content"] = new_content
truncated.append(new_msg)
return truncated