81be17d5cd
Downscale processed PNGs only on the inference send path (keep alpha, never upscale). Stored inventory/Woo blobs unchanged. Adds Pillow + unit/e2e-style tests; cite tests on METADATA-35.
458 lines
20 KiB
Python
458 lines
20 KiB
Python
"""Inference (S7): prompt revisions (REST), Call 1 vision classification, Call 2
|
|
content prose, test-inference, inference-off server description, inference_log.
|
|
Cites METADATA-35..115, SETTINGS-30/45/90.
|
|
"""
|
|
|
|
import base64
|
|
import hashlib
|
|
import io
|
|
import json
|
|
import time
|
|
import urllib.error
|
|
import urllib.request
|
|
from typing import Any
|
|
|
|
from fastapi import APIRouter
|
|
from fastapi.responses import JSONResponse
|
|
from PIL import Image
|
|
|
|
from .db import get_connection
|
|
from .crypto import decrypt
|
|
|
|
router = APIRouter(prefix="/api/v1")
|
|
|
|
# METADATA-35: Call 1 (and Call 2) send clean processed photos at 1024 px long edge or less.
|
|
_INFER_MAX_LONG_EDGE = 1024
|
|
|
|
|
|
def fit_long_edge_png(png_bytes: bytes, max_long_edge: int = _INFER_MAX_LONG_EDGE) -> bytes:
|
|
"""Downscale a PNG so max(width, height) <= max_long_edge. Never upscale. Keep alpha.
|
|
|
|
Used only for the inference request payload; stored processed_blob / Woo media stay full size.
|
|
Unreadable or non-image bytes are returned unchanged so a bad blob cannot crash Call 1.
|
|
"""
|
|
if not png_bytes or max_long_edge < 1:
|
|
return png_bytes
|
|
try:
|
|
im = Image.open(io.BytesIO(png_bytes))
|
|
im.load()
|
|
except Exception:
|
|
return png_bytes
|
|
w, h = im.size
|
|
long_edge = max(w, h)
|
|
if long_edge <= max_long_edge:
|
|
# Already within budget; re-encode only if we need a stable PNG (caller always base64s).
|
|
return png_bytes
|
|
scale = max_long_edge / float(long_edge)
|
|
nw = max(1, int(round(w * scale)))
|
|
nh = max(1, int(round(h * scale)))
|
|
# Preserve alpha: convert palette/etc. to RGBA when needed, LANCZOS downscale.
|
|
if im.mode not in ("RGB", "RGBA", "L", "LA"):
|
|
im = im.convert("RGBA")
|
|
out = im.resize((nw, nh), Image.Resampling.LANCZOS)
|
|
buf = io.BytesIO()
|
|
out.save(buf, format="PNG", optimize=True)
|
|
return buf.getvalue()
|
|
|
|
|
|
def _image_part_from_processed(png_bytes: bytes) -> dict:
|
|
"""Build one OpenAI-style image_url part from a processed PNG (METADATA-35 sized)."""
|
|
fitted = fit_long_edge_png(png_bytes)
|
|
b64 = base64.b64encode(fitted).decode()
|
|
return {"type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64}"}}
|
|
|
|
CALL1_SEED = (
|
|
"Classify the slab's species, wood categories, edge type, and figure from the supplied "
|
|
"photos, choosing ONLY from the supplied synced taxonomy lists (invent no category, term, "
|
|
"or id outside them). Map free-text observations (character, inclusions, voids, checks) to "
|
|
"the supplied feature-tag list only. Grade is NOT suggested — leave it out.\n"
|
|
"Return a single JSON object with EXACTLY these top-level keys, in this shape:\n"
|
|
"{\"species_id\": <id or null>, \"species_confidence\": <0-1>, "
|
|
"\"wood_category_ids\": [<id>, ...], \"wood_category_confidence\": <0-1>, "
|
|
"\"edge_type_term_id\": <id or null>, \"edge_confidence\": <0-1>, "
|
|
"\"figure_term_ids\": [<id>, ...], \"figure_confidence\": <0-1>, "
|
|
"\"character\": [...], \"inclusions\": [...], \"voids\": [...], \"checks\": [...]}\n"
|
|
"Each *_confidence is a top-level sibling of its field (do NOT nest confidences under a "
|
|
"'confidence' object). Use 0-1 numbers; set a field to null / [] when you cannot identify it."
|
|
)
|
|
CALL2_SEED = (
|
|
"Write the long product description for the slab from the supplied photos and listing "
|
|
"facts. The name and short description are already built, so do not repeat them. Use the "
|
|
"brand voice and geo context given. The dimensions in the fact block are exact: use those "
|
|
"strings and invent no numbers. Write prose a premium lumber buyer would read."
|
|
)
|
|
_SEEDS = {"call1": CALL1_SEED, "call2": CALL2_SEED}
|
|
_THRESHOLD = 0.7
|
|
|
|
|
|
def _error(code: str, message: str, detail: Any = None) -> dict:
|
|
return {"error": {"code": code, "message": message, "detail": detail}}
|
|
|
|
|
|
def _now() -> str:
|
|
from datetime import datetime, timezone
|
|
|
|
return datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None).isoformat()
|
|
|
|
|
|
def _hash(text: str) -> str:
|
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def _seed_prompts(conn) -> None:
|
|
for name, seed in _SEEDS.items():
|
|
if conn.execute("SELECT 1 FROM prompts WHERE name=?", (name,)).fetchone():
|
|
continue
|
|
cur = conn.execute(
|
|
"INSERT INTO prompts(name, active_revision_id, created_at, updated_at) VALUES (?,?,?,?)",
|
|
(name, None, _now(), _now()),
|
|
)
|
|
pid = cur.lastrowid
|
|
cur2 = conn.execute(
|
|
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)",
|
|
(pid, seed, _hash(seed), _now(), "seed"),
|
|
)
|
|
conn.execute("UPDATE prompts SET active_revision_id=? WHERE id=?", (cur2.lastrowid, pid))
|
|
|
|
|
|
def _active_prompt(conn, call: str) -> tuple[int, str] | None:
|
|
row = conn.execute(
|
|
"SELECT p.id, pr.id, pr.content FROM prompts p LEFT JOIN prompt_revisions pr ON p.active_revision_id=pr.id WHERE p.name=?",
|
|
(call,),
|
|
).fetchone()
|
|
if row is None or row[2] is None:
|
|
return None
|
|
return row[1], row[2]
|
|
|
|
|
|
# ---------- Prompt revision REST (METADATA-106) ----------
|
|
@router.post("/prompts/{call}/revisions", status_code=201)
|
|
async def append_revision(call: str, payload: dict[str, Any]) -> dict:
|
|
if call not in _SEEDS:
|
|
return JSONResponse(status_code=404, content=_error("not_found", "unknown call"))
|
|
content = payload.get("content")
|
|
if not content:
|
|
return JSONResponse(status_code=400, content=_error("validation", "content required"))
|
|
conn = get_connection()
|
|
_seed_prompts(conn)
|
|
prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone()
|
|
cur = conn.execute(
|
|
"INSERT INTO prompt_revisions(prompt_id, content, content_hash, created_at, created_by) VALUES (?,?,?,?,?)",
|
|
(prow[0], content, _hash(content), _now(), "api"),
|
|
)
|
|
conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (cur.lastrowid, _now(), prow[0]))
|
|
conn.commit()
|
|
return {"id": cur.lastrowid, "prompt": call, "content_hash": _hash(content)}
|
|
|
|
|
|
@router.post("/prompts/{call}/activate")
|
|
async def activate_revision(call: str, payload: dict[str, Any]) -> dict:
|
|
if call not in _SEEDS:
|
|
return JSONResponse(status_code=404, content=_error("not_found", "unknown call"))
|
|
rev_id = payload.get("revision_id")
|
|
conn = get_connection()
|
|
_seed_prompts(conn)
|
|
prow = conn.execute("SELECT id FROM prompts WHERE name=?", (call,)).fetchone()
|
|
row = conn.execute(
|
|
"SELECT id FROM prompt_revisions WHERE id=? AND prompt_id=?", (rev_id, prow[0])
|
|
).fetchone()
|
|
if row is None:
|
|
return JSONResponse(status_code=404, content=_error("not_found", "revision not found"))
|
|
conn.execute("UPDATE prompts SET active_revision_id=?, updated_at=? WHERE id=?", (rev_id, _now(), prow[0]))
|
|
conn.commit()
|
|
return {"ok": True, "active_revision_id": rev_id}
|
|
|
|
|
|
@router.get("/prompts/{call}")
|
|
async def get_prompt(call: str) -> dict:
|
|
if call not in _SEEDS:
|
|
return JSONResponse(status_code=404, content=_error("not_found", "unknown call"))
|
|
conn = get_connection()
|
|
_seed_prompts(conn)
|
|
prow = conn.execute("SELECT * FROM prompts WHERE name=?", (call,)).fetchone()
|
|
revs = conn.execute(
|
|
"SELECT id, content_hash, created_at, created_by FROM prompt_revisions WHERE prompt_id=? ORDER BY id",
|
|
(prow[0],),
|
|
).fetchall()
|
|
active = conn.execute(
|
|
"SELECT content FROM prompt_revisions WHERE id=?", (prow["active_revision_id"],)
|
|
).fetchone()
|
|
return {
|
|
"name": call,
|
|
"active_revision_id": prow["active_revision_id"],
|
|
"active_content": active[0] if active else None,
|
|
"revisions": [dict(r) for r in revs],
|
|
}
|
|
|
|
|
|
# ---------- Inference client (OpenAI-compatible, stdlib) ----------
|
|
class InferenceClient:
|
|
def __init__(self, base_url: str, api_key: str, model: str, timeout: float = 60.0):
|
|
self.base_url = base_url.rstrip("/")
|
|
self.api_key = api_key
|
|
self.model = model
|
|
self.timeout = timeout
|
|
|
|
def chat(self, messages: list[dict]) -> str:
|
|
url = f"{self.base_url}/chat/completions"
|
|
body = json.dumps({"model": self.model, "messages": messages}).encode("utf-8")
|
|
req = urllib.request.Request(
|
|
url, data=body,
|
|
headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
|
|
method="POST",
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=self.timeout) as resp:
|
|
data = json.loads(resp.read().decode("utf-8"))
|
|
except urllib.error.HTTPError as e:
|
|
raise InferenceError(f"inference HTTP {e.code}") from e
|
|
except urllib.error.URLError as e:
|
|
raise InferenceConnectionError(f"inference unreachable: {e.reason}") from e
|
|
return data["choices"][0]["message"]["content"]
|
|
|
|
|
|
class InferenceError(Exception):
|
|
pass
|
|
|
|
|
|
class InferenceConnectionError(InferenceError):
|
|
pass
|
|
|
|
|
|
def _inference_creds(conn) -> tuple[str, str, str]:
|
|
b = conn.execute("SELECT value_enc FROM settings WHERE key='inference_base_url'").fetchone()
|
|
k = conn.execute("SELECT value_enc FROM settings WHERE key='inference_api_key'").fetchone()
|
|
m = conn.execute("SELECT value FROM settings WHERE key='inference_model'").fetchone()
|
|
return (decrypt(b[0]) if b and b[0] else ""), (decrypt(k[0]) if k and k[0] else ""), (m[0] if m and m[0] else "")
|
|
|
|
|
|
def _enabled(conn, key: str) -> bool:
|
|
row = conn.execute("SELECT value FROM settings WHERE key=?", (key,)).fetchone()
|
|
return bool(row and row[0] in ("true", "True", "1"))
|
|
|
|
|
|
@router.post("/slabs/{slab_id}/inference/call1")
|
|
async def call1(slab_id: str) -> dict:
|
|
conn = get_connection()
|
|
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
|
if row is None:
|
|
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
|
if not _enabled(conn, "inference_enabled"):
|
|
return JSONResponse(status_code=409, content=_error("inference_disabled", "Inference is off"))
|
|
base, key, model = _inference_creds(conn)
|
|
if not base or not key:
|
|
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
|
client = InferenceClient(base, key, model)
|
|
act = _active_prompt(conn, "call1")
|
|
prompt_text = act[1] if act else CALL1_SEED
|
|
# Build vision message with processed photos (METADATA-35: long edge <= 1024) + taxonomy.
|
|
photos = conn.execute(
|
|
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
|
(slab_id,),
|
|
).fetchall()
|
|
image_parts = [_image_part_from_processed(p[0]) for p in photos]
|
|
tax = _taxonomy_lists(conn)
|
|
text = (f"{prompt_text}\n\nSYNCED TAXONOMY (only legal choices):\n{json.dumps(tax)}\n\n"
|
|
f"SKU={row['sku']} length_in={row['length_in']} thickness_in={row['thickness_in']}")
|
|
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
|
t0 = time.time()
|
|
try:
|
|
raw = client.chat(messages)
|
|
except InferenceConnectionError as e:
|
|
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
|
|
except InferenceError as e:
|
|
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
|
latency = int((time.time() - t0) * 1000)
|
|
parsed = _parse_json(raw)
|
|
_log_call(conn, slab_id, 1, act[0] if act else None, model, messages, raw, latency)
|
|
# Apply confidence gate (METADATA-45).
|
|
suggestions = _gate(parsed)
|
|
return {"ok": True, "suggestions": suggestions}
|
|
|
|
|
|
def _taxonomy_lists(conn) -> dict:
|
|
# METADATA-10: categories are leaves only; parents group only. Resolve groupers BY SLUG
|
|
# (never hardcoded id, QUALITY-105) and match attribute names case-insensitively (the
|
|
# live Woo attribute is 'Edge type', not 'Edge Type').
|
|
out = {"species_leaves": [], "wood_category_leaves": [], "edge_terms": [], "figure_terms": [],
|
|
"grade_terms": [], "thickness_terms": [], "moisture_terms": [], "feat_tags": []}
|
|
# species = leaves under the 'species' grouper
|
|
sp = conn.execute("SELECT id FROM woo_categories WHERE slug='species'").fetchone()
|
|
if sp:
|
|
for r in conn.execute(
|
|
"SELECT id,name FROM woo_categories WHERE parent_id=?", (sp[0],)
|
|
).fetchall():
|
|
out["species_leaves"].append({"id": r[0], "name": r[1]})
|
|
# wood categories = leaves under the 'wcat' grouper (leaves may sit under sub-groupers).
|
|
wc = conn.execute("SELECT id FROM woo_categories WHERE slug='wcat'").fetchone()
|
|
if wc:
|
|
kids = [r[0] for r in conn.execute(
|
|
"SELECT id FROM woo_categories WHERE parent_id=?", (wc[0],)
|
|
).fetchall()]
|
|
leaf_ids = []
|
|
for cid in kids:
|
|
has_kids = conn.execute(
|
|
"SELECT 1 FROM woo_categories WHERE parent_id=? LIMIT 1", (cid,)
|
|
).fetchone()
|
|
if has_kids:
|
|
leaf_ids.extend(r[0] for r in conn.execute(
|
|
"SELECT id FROM woo_categories WHERE parent_id=?", (cid,)
|
|
).fetchall())
|
|
else:
|
|
leaf_ids.append(cid)
|
|
if leaf_ids:
|
|
q = ",".join("?" * len(leaf_ids))
|
|
for r in conn.execute(
|
|
"SELECT id,name FROM woo_categories WHERE id IN (%s)" % q, leaf_ids
|
|
).fetchall():
|
|
out["wood_category_leaves"].append({"id": r[0], "name": r[1]})
|
|
# attribute terms, case-insensitive on the attribute name. Grade stays in the synced set
|
|
# (owner reference) but the model is told not to suggest it (METADATA-40).
|
|
for attr, key in (("Edge type", "edge_terms"), ("Figure", "figure_terms"),
|
|
("Grade", "grade_terms"), ("Thickness", "thickness_terms"),
|
|
("Moisture", "moisture_terms")):
|
|
rows = conn.execute(
|
|
"SELECT t.id,t.name FROM woo_attribute_terms t JOIN woo_attributes a ON t.attribute_id=a.id "
|
|
"WHERE lower(a.name)=lower(?)",
|
|
(attr,),
|
|
).fetchall()
|
|
out[key] = [{"id": r[0], "name": r[1]} for r in rows]
|
|
for r in conn.execute("SELECT id,name FROM woo_tags WHERE slug LIKE 'feat-%'").fetchall():
|
|
out["feat_tags"].append({"id": r[0], "name": r[1]})
|
|
return out
|
|
|
|
|
|
def _parse_json(raw: str) -> dict:
|
|
try:
|
|
s = raw.strip()
|
|
if s.startswith("```"):
|
|
s = s.split("```")[1]
|
|
if s.startswith("json"):
|
|
s = s[4:]
|
|
return json.loads(s)
|
|
except (ValueError, IndexError):
|
|
return {}
|
|
|
|
|
|
def _gate(parsed: dict) -> dict:
|
|
"""Apply the 0.7 confidence gate; below-threshold fields are omitted (METADATA-45).
|
|
|
|
Confidence is read from the flat '<field>_confidence' keys; as a safety net it also
|
|
understands the nested 'confidence: {<field>: n}' shape some models emit, so a shape
|
|
wobble cannot silently nuke every suggestion. Grade is not suggested (METADATA-40).
|
|
"""
|
|
# Map each field to its confidence key. The field name and the confidence key do NOT
|
|
# share a stem (e.g. field 'species_id' -> key 'species_confidence'), so derive it
|
|
# explicitly rather than f"{field}_confidence".
|
|
conf_key = {
|
|
"species_id": "species_confidence",
|
|
"wood_category_ids": "wood_category_confidence",
|
|
"edge_type_term_id": "edge_confidence",
|
|
"figure_term_ids": "figure_confidence",
|
|
}
|
|
conf_obj = parsed.get("confidence") if isinstance(parsed.get("confidence"), dict) else {}
|
|
def _conf(field: str) -> Any:
|
|
k = conf_key.get(field, f"{field}_confidence")
|
|
if k in conf_obj:
|
|
return conf_obj[k]
|
|
return parsed.get(k)
|
|
def keep(field: str) -> Any:
|
|
conf = _conf(field)
|
|
if isinstance(conf, (int, float)) and conf >= _THRESHOLD and field in parsed:
|
|
return parsed[field]
|
|
return None
|
|
|
|
out: dict[str, Any] = {}
|
|
if v := keep("species_id"):
|
|
out["species_id"] = v
|
|
if v := keep("wood_category_ids"):
|
|
out["wood_category_ids"] = v
|
|
if v := keep("edge_type_term_id"):
|
|
out["edge_type_term_id"] = v
|
|
if v := keep("figure_term_ids"):
|
|
out["figure_term_ids"] = v
|
|
# grade intentionally not gated/suggested (METADATA-40: owner judgment).
|
|
for k in ("character", "inclusions", "voids", "checks"):
|
|
if parsed.get(k):
|
|
out[k] = parsed[k]
|
|
return out
|
|
|
|
|
|
@router.post("/slabs/{slab_id}/inference/call2")
|
|
async def call2(slab_id: str) -> dict:
|
|
conn = get_connection()
|
|
row = conn.execute("SELECT * FROM slabs WHERE id=? AND deleted_at IS NULL", (slab_id,)).fetchone()
|
|
if row is None:
|
|
return JSONResponse(status_code=404, content=_error("not_found", "Slab not found"))
|
|
if not _enabled(conn, "content_llm_enabled"):
|
|
return JSONResponse(status_code=409, content=_error("content_disabled", "Content generation is off"))
|
|
base, key, model = _inference_creds(conn)
|
|
if not base or not key:
|
|
return JSONResponse(status_code=409, content=_error("inference_unconfigured", "Inference is not configured"))
|
|
client = InferenceClient(base, key, model)
|
|
act = _active_prompt(conn, "call2")
|
|
prompt_text = act[1] if act else CALL2_SEED
|
|
photos = conn.execute(
|
|
"SELECT processed_blob FROM slab_photos WHERE slab_id=? AND processed_blob IS NOT NULL ORDER BY seq LIMIT 8",
|
|
(slab_id,),
|
|
).fetchall()
|
|
image_parts = [_image_part_from_processed(p[0]) for p in photos]
|
|
brand = conn.execute("SELECT value FROM settings WHERE key='brand_voice'").fetchone()
|
|
geo = conn.execute("SELECT value FROM settings WHERE key='geo_context'").fetchone()
|
|
text = (f"{prompt_text}\n\nDIMENSION FACT BLOCK (exact, invent no numbers):\n"
|
|
f"length={row['length_in']}in thickness={row['thickness_in']}in "
|
|
f"avg_width={row['width_avg_in']}in width_min={row['width_min_in']}in width_max={row['width_max_in']}in "
|
|
f"bdft={row['bdft']}\n\nBrand voice: {brand[0] if brand else ''}\nGeo: {geo[0] if geo else ''}")
|
|
messages = [{"role": "user", "content": image_parts + [{"type": "text", "text": text}]}]
|
|
t0 = time.time()
|
|
try:
|
|
raw = client.chat(messages)
|
|
except InferenceConnectionError as e:
|
|
return JSONResponse(status_code=503, content=_error("inference_unreachable", str(e), {"recoverable": True}))
|
|
except InferenceError as e:
|
|
return JSONResponse(status_code=502, content=_error("inference_failed", str(e), {"recoverable": True}))
|
|
latency = int((time.time() - t0) * 1000)
|
|
_log_call(conn, slab_id, 2, act[0] if act else None, model, messages, raw, latency)
|
|
# Store as description, mark content_source=llm.
|
|
conn.execute("UPDATE slabs SET description=?, content_source='llm', updated_at=? WHERE id=?",
|
|
(raw.strip(), _now(), slab_id))
|
|
conn.commit()
|
|
return {"ok": True, "description": raw.strip()}
|
|
|
|
|
|
def server_description(slab: dict) -> str:
|
|
"""Inference-off long description (METADATA-70)."""
|
|
return (
|
|
f"A {slab.get('title', 'slab')} from the mill, measured at "
|
|
f"{slab.get('length_in')}in long, {slab.get('width_avg_in')}in average width, and "
|
|
f"{slab.get('thickness_in')}in thick ({slab.get('bdft')} bdft). "
|
|
"Ready to ship."
|
|
)
|
|
|
|
|
|
def _log_call(conn, slab_id: str, call: int, rev_id: int | None, model: str,
|
|
request: list, response: str, latency_ms: int) -> None:
|
|
conn.execute(
|
|
"INSERT INTO inference_log(slab_id, call, prompt_revision_id, model, request_payload, response_payload, latency_ms, created_at) "
|
|
"VALUES (?,?,?,?,?,?,?,?)",
|
|
(slab_id, call, rev_id, model, json.dumps(request)[:200000], response, latency_ms, _now()),
|
|
)
|
|
conn.commit()
|
|
|
|
|
|
@router.post("/admin/test-inference")
|
|
async def test_inference() -> dict:
|
|
conn = get_connection()
|
|
base, key, model = _inference_creds(conn)
|
|
if not base or not key:
|
|
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": "Inference is not configured"}
|
|
client = InferenceClient(base, key, model, timeout=20.0)
|
|
t0 = time.time()
|
|
try:
|
|
client.chat([{"role": "user", "content": "Reply with the single word: ok"}])
|
|
except InferenceConnectionError as e:
|
|
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
|
|
except InferenceError as e:
|
|
return {"ok": False, "mode": "fail", "vision_capable": False, "detail": str(e)}
|
|
detail = f"reachable in {int((time.time()-t0)*1000)}ms"
|
|
return {"ok": True, "mode": "stateless", "vision_capable": True, "detail": detail} |