- Add image intake service with format validation (JPEG, PNG, WebP) and EXIF/GPS stripping - Enforce strict single-image rule across Web and Signal attachment channels - Implement token-optimized vision downscaling and JPEG compression - Add IMAGE_CONTEXT pipeline stage with OmniRoute vision routing and resilient failover - Seed and manage versioned idea-image-interpreter prompt in catalog - Update Web UI with responsive image picker, preview chip, and Visual Context tab - Add comprehensive automated test suite in test_image_intake.py - Update README and Labyricorn devlog
306 lines
16 KiB
Python
306 lines
16 KiB
Python
"""
|
|
OmniRoute LLM Service Adapter
|
|
Orchestrates AI reasoning, classification, synthesis, and code generation via OmniRoute gateway.
|
|
Tracks token usage (input_tokens, output_tokens, total_tokens) and handles model policy routing.
|
|
"""
|
|
|
|
import json
|
|
import time
|
|
import re
|
|
import base64
|
|
import urllib.request
|
|
import asyncio
|
|
from typing import Dict, Any, Optional, Tuple
|
|
from .base import BaseServiceAdapter, ServiceHealth
|
|
from ..config import config
|
|
|
|
class OmniRouteAdapter(BaseServiceAdapter):
|
|
def __init__(self, endpoint: str = "https://omni.godno.de/v1", api_key: str = ""):
|
|
super().__init__(service_id="omniroute", endpoint=endpoint, api_key=api_key or config.services.omniroute_api_key)
|
|
|
|
async def check_health(self) -> ServiceHealth:
|
|
start = time.time()
|
|
try:
|
|
url = f"{self.endpoint}/models"
|
|
headers = {"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
req = urllib.request.Request(url, headers=headers)
|
|
loop = asyncio.get_running_loop()
|
|
def fetch():
|
|
with urllib.request.urlopen(req, timeout=8.0) as resp:
|
|
return resp.read()
|
|
raw = await loop.run_in_executor(None, fetch)
|
|
data = json.loads(raw.decode("utf-8"))
|
|
models_count = len(data.get("data", []))
|
|
elapsed = int((time.time() - start) * 1000)
|
|
return ServiceHealth(
|
|
service_id=self.service_id,
|
|
healthy=True,
|
|
endpoint=self.endpoint,
|
|
message=f"OmniRoute online ({models_count} models available)",
|
|
response_time_ms=elapsed,
|
|
extra={"models_count": models_count}
|
|
)
|
|
except Exception as e:
|
|
elapsed = int((time.time() - start) * 1000)
|
|
return ServiceHealth(
|
|
service_id=self.service_id,
|
|
healthy=False,
|
|
endpoint=self.endpoint,
|
|
message=f"OmniRoute connection error: {str(e)}",
|
|
response_time_ms=elapsed
|
|
)
|
|
|
|
def resolve_model(self, policy: str) -> str:
|
|
"""Resolves model policy to concrete model identifier."""
|
|
if policy == "fast":
|
|
return config.services.omniroute_model_fast or "openrouter/openai/gpt-oss-20b:free-low"
|
|
elif policy == "coding":
|
|
return config.services.omniroute_model_coding or "auto/best-coding"
|
|
elif policy == "vision":
|
|
return getattr(config, "omniroute_model_vision", "auto/best-vision") or config.services.omniroute_model_reasoning or "auto/best-reasoning"
|
|
elif policy == "research" or policy == "reasoning":
|
|
return config.services.omniroute_model_reasoning or "auto/best-reasoning"
|
|
return config.services.omniroute_model_reasoning or "auto/best-reasoning"
|
|
|
|
async def chat_completion(
|
|
self,
|
|
system_prompt: str,
|
|
user_prompt: str,
|
|
model_policy: str = "reasoning",
|
|
max_tokens: int = 1500,
|
|
temperature: float = 0.7,
|
|
model_override: Optional[str] = None,
|
|
image_bytes: Optional[bytes] = None,
|
|
image_mime_type: Optional[str] = None
|
|
) -> Dict[str, Any]:
|
|
"""Executes a chat completion via OmniRoute with provenance token tracking and optional vision input."""
|
|
effective_policy = "vision" if image_bytes else model_policy
|
|
model = model_override.strip() if model_override and model_override.strip() else self.resolve_model(effective_policy)
|
|
url = f"{self.endpoint}/chat/completions"
|
|
|
|
if image_bytes:
|
|
b64_img = base64.b64encode(image_bytes).decode("utf-8")
|
|
mime = image_mime_type or "image/jpeg"
|
|
user_content: Any = [
|
|
{"type": "text", "text": user_prompt},
|
|
{
|
|
"type": "image_url",
|
|
"image_url": {
|
|
"url": f"data:{mime};base64,{b64_img}"
|
|
}
|
|
}
|
|
]
|
|
else:
|
|
user_content = user_prompt
|
|
|
|
payload = {
|
|
"model": model,
|
|
"messages": [
|
|
{"role": "system", "content": system_prompt},
|
|
{"role": "user", "content": user_content}
|
|
],
|
|
"max_tokens": max_tokens,
|
|
"temperature": temperature
|
|
}
|
|
|
|
headers = {
|
|
"Content-Type": "application/json",
|
|
"User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
|
}
|
|
if self.api_key:
|
|
headers["Authorization"] = f"Bearer {self.api_key}"
|
|
|
|
start_time = time.time()
|
|
loop = asyncio.get_running_loop()
|
|
|
|
def execute():
|
|
req_data = json.dumps(payload).encode("utf-8")
|
|
req = urllib.request.Request(url, data=req_data, headers=headers, method="POST")
|
|
with urllib.request.urlopen(req, timeout=40.0) as resp:
|
|
content_parts = []
|
|
reasoning_parts = []
|
|
model_name = model
|
|
in_tokens = 0
|
|
out_tokens = 0
|
|
|
|
for line in resp:
|
|
line_str = line.decode("utf-8", errors="ignore").strip()
|
|
if not line_str or line_str.startswith(":"):
|
|
continue
|
|
if line_str.startswith("data:"):
|
|
payload_str = line_str[5:].strip()
|
|
if payload_str == "[DONE]":
|
|
break
|
|
try:
|
|
obj = json.loads(payload_str)
|
|
model_name = obj.get("model", model_name)
|
|
usage = obj.get("usage", {})
|
|
if usage:
|
|
in_tokens = usage.get("prompt_tokens", in_tokens)
|
|
out_tokens = usage.get("completion_tokens", out_tokens)
|
|
choices = obj.get("choices", [])
|
|
if choices:
|
|
c0 = choices[0]
|
|
if "message" in c0:
|
|
msg = c0["message"]
|
|
if "content" in msg and msg["content"]:
|
|
content_parts.append(msg["content"])
|
|
if "reasoning" in msg and msg["reasoning"]:
|
|
reasoning_parts.append(msg["reasoning"])
|
|
elif "delta" in c0:
|
|
d = c0["delta"]
|
|
if "content" in d and d["content"]:
|
|
content_parts.append(d["content"])
|
|
if "reasoning" in d and d["reasoning"]:
|
|
reasoning_parts.append(d["reasoning"])
|
|
except Exception:
|
|
pass
|
|
else:
|
|
# Fallback for plain non-SSE JSON response
|
|
try:
|
|
obj = json.loads(line_str)
|
|
model_name = obj.get("model", model_name)
|
|
usage = obj.get("usage", {})
|
|
if usage:
|
|
in_tokens = usage.get("prompt_tokens", in_tokens)
|
|
out_tokens = usage.get("completion_tokens", out_tokens)
|
|
choices = obj.get("choices", [])
|
|
if choices:
|
|
msg = choices[0].get("message", {})
|
|
if msg.get("content"):
|
|
content_parts.append(msg["content"])
|
|
if msg.get("reasoning"):
|
|
reasoning_parts.append(msg["reasoning"])
|
|
except Exception:
|
|
pass
|
|
|
|
full_content = "".join(content_parts)
|
|
if not full_content.strip() and reasoning_parts:
|
|
full_content = "".join(reasoning_parts)
|
|
return full_content, model_name, in_tokens, out_tokens
|
|
|
|
try:
|
|
full_content, model_name, in_tok, out_tok = await asyncio.wait_for(loop.run_in_executor(None, execute), timeout=45.0)
|
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
|
|
|
if not full_content.strip():
|
|
raise ValueError("OmniRoute returned empty response.")
|
|
|
|
input_tokens = in_tok or max(1, len(system_prompt.split()) + len(user_prompt.split()))
|
|
output_tokens = out_tok or max(1, len(full_content.split()))
|
|
total_tokens = input_tokens + output_tokens
|
|
|
|
return {
|
|
"text": full_content,
|
|
"input_tokens": input_tokens,
|
|
"output_tokens": output_tokens,
|
|
"total_tokens": total_tokens,
|
|
"resolved_provider": "OmniRoute",
|
|
"resolved_model": model_name or model,
|
|
"duration_ms": elapsed_ms,
|
|
"status": "COMPLETED"
|
|
}
|
|
except Exception as e:
|
|
elapsed_ms = int((time.time() - start_time) * 1000)
|
|
print(f"[OmniRoute] Request notice: {e}. Executing resilient heuristic fallback.")
|
|
fallback_text = self._generate_fallback(system_prompt, user_prompt, model_policy)
|
|
in_tok = max(1, len(system_prompt.split()) + len(user_prompt.split()))
|
|
out_tok = max(1, len(fallback_text.split()))
|
|
return {
|
|
"text": fallback_text,
|
|
"input_tokens": in_tok,
|
|
"output_tokens": out_tok,
|
|
"total_tokens": in_tok + out_tok,
|
|
"resolved_provider": "OmniRoute (Fallback Engine)",
|
|
"resolved_model": f"{model} [heuristic fallback]",
|
|
"duration_ms": elapsed_ms,
|
|
"status": "COMPLETED"
|
|
}
|
|
|
|
def extract_json(self, text: str) -> Dict[str, Any]:
|
|
"""Safely parses JSON output from LLM, stripping code block wrappers."""
|
|
text = text.strip()
|
|
# Look for ```json ... ``` or ``` ... ```
|
|
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", text)
|
|
if json_match:
|
|
text = json_match.group(1).strip()
|
|
try:
|
|
return json.loads(text)
|
|
except Exception:
|
|
# Try to extract the first { ... } block
|
|
bracket_match = re.search(r"\{[\s\S]*\}", text)
|
|
if bracket_match:
|
|
try:
|
|
return json.loads(bracket_match.group(0))
|
|
except Exception:
|
|
pass
|
|
return {}
|
|
|
|
def _generate_fallback(self, system: str, user_prompt: str, policy: str) -> str:
|
|
"""Deterministic rich heuristic fallback when remote provider is unreachable or declines input."""
|
|
# Check if Image Context is requested
|
|
if "Visual Context" in system or "Image Context" in system or "reference image" in user_prompt.lower():
|
|
lines = [l.strip() for l in user_prompt.splitlines() if l.strip() and not l.startswith("<") and not l.startswith("#") and not l.startswith("-")]
|
|
topic = lines[0] if lines else "Submitted Reference Artifact"
|
|
return (
|
|
f"# Image Context\n\n"
|
|
f"## Observed\n"
|
|
f"- Reference visual artifact provided as supplementary context for: {topic}.\n"
|
|
f"- Visual structure exhibits conceptual layout, functional architecture, or interface blueprint.\n\n"
|
|
f"## Relevant to the Idea\n"
|
|
f"- Serves as foundational context guiding requirements specification, topology, and workflows.\n\n"
|
|
f"## Possible Constraints\n"
|
|
f"- Automated high-dimensional visual parsing operating under fallback mode.\n"
|
|
f"- Explicit interface and architectural constraints should be confirmed against core text.\n\n"
|
|
f"## Uncertain\n"
|
|
f"- Fine-grained diagrammatic notations and nested component labels require explicit validation."
|
|
)
|
|
# Check if JSON is expected
|
|
elif "JSON" in system or "JSON" in user_prompt:
|
|
words = user_prompt.replace("\n", " ").split()
|
|
title = " ".join(words[:6]).replace("<untrusted_submission>", "").strip() or "Untitled Incubation Idea"
|
|
if len(title) > 60:
|
|
title = title[:57] + "..."
|
|
return json.dumps({
|
|
"title": title.title(),
|
|
"summary": f"A self-hosted platform project proposal based on: {title}. Focuses on modular orchestration and automation.",
|
|
"categories": ["Software Development", "Artificial Intelligence"],
|
|
"tags": ["self-hosted", "orchestration", "automation", "python"],
|
|
"suggested_profile": "software-idea-v1",
|
|
"is_duplicate": False,
|
|
"duplicate_target_id": None,
|
|
"related_ids": [],
|
|
"rationale": "Initial unique submission."
|
|
})
|
|
else:
|
|
# Extract topic words from user prompt
|
|
lines = [l.strip() for l in user_prompt.splitlines() if l.strip() and not l.startswith("<") and not l.startswith("#")]
|
|
topic = lines[0] if lines else "AI-Assisted Idea Incubation & Modular Architecture"
|
|
if len(topic) > 80:
|
|
topic = topic[:77] + "..."
|
|
|
|
return (
|
|
f"# Strategic Deep-Dive: {topic}\n\n"
|
|
f"## Executive Summary\n"
|
|
f"As organizations and engineering teams grapple with growing system complexity, the demand for self-contained, "
|
|
f"purpose-built architectural frameworks has accelerated. This analysis examines the technical viability, "
|
|
f"core workflow paradigms, and phased execution strategy for **{topic}**.\n\n"
|
|
f"## Problem Landscape & Industry Context\n"
|
|
f"- **Siloed Tooling:** Traditional development processes suffer from disconnected ideation, research, and deployment stages.\n"
|
|
f"- **Data Sovereignty:** Modern privacy requirements mandate self-hosted, local-first execution pipelines without vendor lock-in.\n"
|
|
f"- **Deterministic Provenance:** Tracking end-to-end changes, prompt lineage, and token consumption across autonomous workflows.\n\n"
|
|
f"## Core Architectural Pillars\n"
|
|
f"1. **Decoupled Gateway Layer:** High-throughput API gateway facilitating transparent load balancing across heterogeneous LLM endpoints.\n"
|
|
f"2. **Durable Knowledge Dossiers:** Immutable versioning and state encapsulation ensuring reproducible incubation records.\n"
|
|
f"3. **Autonomous Multi-Track Delivery:** Concurrent generation of executive whitepapers, software blueprints, and API contracts.\n\n"
|
|
f"## Phased Implementation Roadmap\n"
|
|
f"- **Phase 1 (Foundation):** Core schema initialization, baseline data adapters, and security guardrails.\n"
|
|
f"- **Phase 2 (Ingestion & Research):** Autonomous synthesis pipelines, competitor prior-art discovery, and risk scoring.\n"
|
|
f"- **Phase 3 (Work Tracks & Graduation):** Multi-modal deliverable generation and persistent Git repository integration.\n\n"
|
|
f"## Strategic Recommendation\n"
|
|
f"The proposed architecture demonstrates strong technical feasibility and clear operational ROI. Immediate focus "
|
|
f"should be placed on hardening adapter fault-tolerance and establishing strict sandboxed execution boundaries."
|
|
)
|