# retoor <retoor@molodetz.nl>
import asyncio
import hashlib
import logging
import time
from collections import OrderedDict
from typing import Any, Optional
import httpx
from devplacepy.services.openai_gateway.config import VISION_INSTRUCTION
from devplacepy.services.openai_gateway.usage import classify_error
logger = logging.getLogger(__name__)
class VisionCache:
def __init__(self, size: int = 256):
self.size = size
self._store: "OrderedDict[str, str]" = OrderedDict()
def get(self, key: str) -> Optional[str]:
if self.size <= 0:
return None
value = self._store.get(key)
if value is not None:
self._store.move_to_end(key)
return value
def put(self, key: str, value: str) -> None:
if self.size <= 0:
return
self._store[key] = value
self._store.move_to_end(key)
while len(self._store) > self.size:
self._store.popitem(last=False)
def __len__(self) -> int:
return len(self._store)
def has_vision_blocks(content: Any) -> bool:
if not isinstance(content, list):
return False
return any(
isinstance(b, dict) and b.get("type") in ("image_url", "image") for b in content
)
def split_text_and_images(content: list) -> tuple[str, list]:
texts: list[str] = []
images: list[dict] = []
for block in content:
if not isinstance(block, dict):
continue
btype = block.get("type")
if btype == "text":
t = block.get("text", "")
if t:
texts.append(t)
elif btype in ("image_url", "image"):
images.append(block)
return "\n".join(texts).strip(), images
def _image_cache_key(image_block: dict) -> str:
iu = image_block.get("image_url") or image_block.get("image") or ""
url = iu.get("url", "") if isinstance(iu, dict) else str(iu)
return hashlib.sha256(url.encode("utf-8", errors="replace")).hexdigest()[:32]
def _format_vision_block(descriptions: list) -> str:
if len(descriptions) == 1:
return f"[Image seen by vision model:\n{descriptions[0]}\n]"
parts = [f"Image {i}:\n{d}" for i, d in enumerate(descriptions, 1)]
return (
f"[{len(descriptions)} images seen by vision model:\n"
+ "\n\n".join(parts)
+ "\n]"
)
class VisionAugmenter:
def __init__(
self,
vision_url: str,
vision_model: str,
vision_key: str,
cache: VisionCache,
referer: str = "",
title: str = "",
ledger=None,
owner: tuple = ("unknown", "unknown"),
pricing=None,
context_map=None,
app_reference: str = "default",
):
self.vision_url = vision_url
self.vision_model = vision_model
self.vision_key = vision_key
self.cache = cache
self.referer = referer
self.title = title
self.ledger = ledger
self.owner = owner
self.pricing = pricing
self.context_map = context_map or {}
self.app_reference = app_reference
self.calls = 0
self.cost_usd = 0.0
def _record(self, latency_ms, status_code, success, category, usage):
if self.ledger is None or self.pricing is None:
return
row = self.ledger.record(
{
"owner_kind": self.owner[0],
"owner_id": self.owner[1],
"backend": "vision",
"endpoint": "chat/completions",
"model": self.vision_model,
"requested_model": self.vision_model,
"temperature": 0.2,
"upstream_latency_ms": latency_ms,
"total_latency_ms": latency_ms,
"status_code": status_code,
"success": success,
"error_category": category,
"usage": usage,
"app_reference": self.app_reference,
},
self.pricing,
self.context_map,
)
if row:
self.cost_usd += float(row.get("cost_usd") or 0.0)
async def _describe_one(self, client: httpx.AsyncClient, image_block: dict) -> str:
if not self.vision_key:
return "[vision unavailable: vision API key not configured]"
payload = {
"model": self.vision_model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": VISION_INSTRUCTION},
image_block,
],
}
],
"temperature": 0.2,
"stream": False,
}
headers = {
"Authorization": f"Bearer {self.vision_key}",
"Content-Type": "application/json",
}
if self.referer:
headers["HTTP-Referer"] = self.referer
if self.title:
headers["X-Title"] = self.title
start = time.monotonic()
try:
resp = await client.post(self.vision_url, json=payload, headers=headers)
except httpx.RequestError as e:
logger.warning("vision connection failed: %s", e)
self._record(
(time.monotonic() - start) * 1000,
502,
False,
classify_error(0, e),
None,
)
return f"[vision call failed: {e}]"
latency_ms = (time.monotonic() - start) * 1000
if resp.status_code != 200:
logger.warning("vision %s: %s", resp.status_code, resp.text[:200])
self._record(
latency_ms,
resp.status_code,
False,
classify_error(resp.status_code, None, resp.text),
None,
)
return f"[vision failed: HTTP {resp.status_code}]"
try:
data = resp.json()
text = data["choices"][0]["message"].get("content") or ""
self._record(latency_ms, 200, True, None, data.get("usage"))
return text.strip() or "[vision returned empty response]"
except (KeyError, IndexError, ValueError) as e:
self._record(latency_ms, 200, False, "gateway", None)
return f"[vision parse error: {e}]"
async def _describe_images(self, client: httpx.AsyncClient, images: list) -> list:
keys = [_image_cache_key(img) for img in images]
descriptions: list = [self.cache.get(k) for k in keys]
miss = [i for i, d in enumerate(descriptions) if d is None]
if miss:
self.calls += len(miss)
results = await asyncio.gather(
*[self._describe_one(client, images[i]) for i in miss]
)
for idx, result in zip(miss, results):
descriptions[idx] = result
if not result.startswith("[vision"):
self.cache.put(keys[idx], result)
return [d or "" for d in descriptions]
async def augment_messages(self, client: httpx.AsyncClient, messages: list) -> list:
if not any(
has_vision_blocks(m.get("content")) for m in messages if isinstance(m, dict)
):
return messages
out: list = []
for m in messages:
content = m.get("content") if isinstance(m, dict) else None
if not has_vision_blocks(content):
out.append(m)
continue
user_text, images = split_text_and_images(content)
descriptions = await self._describe_images(client, images)
vision_block = _format_vision_block(descriptions)
merged = f"{user_text}\n\n{vision_block}" if user_text else vision_block
new_msg = dict(m)
new_msg["content"] = merged
out.append(new_msg)
return out