- Remove the keyword-rules agent from typed chat: fallback_agent, parse_look_command (+ tables), the copy-edit parser and _rules_check are gone. If every model fails: 'The assistant is busy, please try again in a moment.' with a Retry button (agent 'unavailable', no actions). Clicks, forms and payments stay deterministic (fallback_event); the landing view is a deterministic featured-listings panel; the Sell button opens the empty form. - Local model via llama.cpp llama-server directly (ops/local-llm-watchdog.sh, loopback only, restart on exit/hang, crash backoff, log rotation; deploy.sh ensures it runs). Lean: no vision projector, no weight repack (mmapped), ctx 6144, 1 slot, q8 KV, 6 threads at nice 10, poll 0, 1 GiB prompt cache. Idle 0% CPU, 2.9 GB RSS (0.6 GB private). - Local requests use a JSON GBNF grammar, worked examples in the cached system prompt, and a labelled visitor message. 10/10 correct on the real prompt, warm 4-13 s. - Primer re-primes after a model-server restart (llama-server /metrics counter) and otherwise stays idle. - Fix: hosted calls kept the Authorization header off when a local model was configured (cfg['local'] is a dict on hosted configs); now only the local server itself skips the key. Test added. - Badge/chat header: 'AI: busy', 'AI: quota used up', 'AI: unavailable'; no 'built-in'/'checked by rules' texts. - Tests: proxy checks expect the busy reply (never rules), clicks still work while busy, no-model server, local grammar/ prompt/no key; browser checks for busy + Retry; e2e_http accepts busy for typed turns unless REQUIRE_LLM=1; test_copyedit.py replaced by test_copy_defaults.py; README updated.
788 lines
45 KiB
Python
788 lines
45 KiB
Python
"""Market agent: LLM-driven when a key is available, otherwise a deterministic fallback.
|
|
|
|
Returns {reply, actions} where actions is a list of UI action objects from a fixed
|
|
component set the frontend knows how to render.
|
|
"""
|
|
from __future__ import annotations
|
|
import concurrent.futures
|
|
import threading, json, os, re, time, urllib.request, urllib.error
|
|
from .store import new_id, now
|
|
|
|
FEE_BPS = int(os.environ.get("AGENT_MARKET_FEE_BPS", "500")) # 5.00%
|
|
FEE_RATE = FEE_BPS / 10000.0
|
|
|
|
UI_SCHEMA = """
|
|
You are Market Guide, the AI agent that drives the page of Agent Market, a TEST DEMO marketplace
|
|
where people and AI agents buy and sell products and services. All listings are fake test data and
|
|
all payments are TEST MODE ONLY (no real money).
|
|
|
|
You control what the page shows by returning UI actions. Reply with ONE JSON object and nothing else
|
|
(no markdown, no prose outside JSON):
|
|
{"reply": "<short friendly message, max 60 words>", "actions": [ ... ]}
|
|
|
|
Allowed actions (use only these types; listing ids must come from CATALOG below, never invent them):
|
|
- {"type":"show_cards","payload":{"heading":"...","listing_ids":["lst_..."]}} list matching items
|
|
- {"type":"show_compare","payload":{"heading":"...","listing_ids":["lst_...","lst_..."]}} side-by-side, 2-4 ids
|
|
- {"type":"show_checkout","payload":{"listing_id":"lst_..."}} online purchase; the panel shows price + fee
|
|
- {"type":"show_cash_meetup","payload":{"listing_id":"lst_...","suggested_place":"...","suggested_time":"..."}} pay cash in person, $0 fee
|
|
- {"type":"show_listing_form","payload":{"defaults":{"title":"...","kind":"product|service","price":<integer cents>,"description":"...","category":"...","area":"Demo Town","payment_options":["online","cash"]}}}
|
|
use when the user wants to sell/list something; prefill everything you can infer, the user presses Publish
|
|
("cash only" => payment_options ["cash"]; "$120" => price 12000)
|
|
- {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} welcome / help
|
|
- {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}}
|
|
|
|
- {"type":"set_theme","payload":{...}} change how the page LOOKS (see LOOK & LAYOUT). Applies instantly, only for this visitor.
|
|
|
|
LOOK & LAYOUT: you control the entire look of the page. Never say you can't change something visual; use set_theme.
|
|
set_theme payload = any subset of this spec (it is merged into the current look):
|
|
{"preset":"retro|minimal|neon|pastel|high contrast|sunset|ocean", // starts from that preset, other keys override
|
|
"reset":true, // back to the default look
|
|
"mode":"dark|light",
|
|
"background":{"kind":"solid|gradient|pattern|image","colors":["#hex",...],"angle":0-360,
|
|
"pattern":"dots|grid|stripes|diagonal|checks|waves","url":"https://images.unsplash.com/... or picsum.photos"},
|
|
"palette":{"bg","surface","card","text","muted","accent","border"}, // CSS colours (#hex, rgb(), names)
|
|
"font":"FONT","headingFont":"FONT","fontScale":0.75-1.75 (1=default),
|
|
"radius":0-32 (0=sharp corners, 24+=pill-ish),"density":"compact|normal|comfy",
|
|
"cardStyle":"flat|outlined|elevated|glass","layout":"grid|list","columns":0-6 (0=auto),
|
|
"copy":{"logo","headline","subhead","footer","chips":[{"label","say"}],"bullets":["..."]}, // page TEXT, see PAGE_TEXT
|
|
"sections":{"order":["hero","chips","bullets","results","footer"],"hidden":["bullets",...]},
|
|
"chat":{"position":"left|right","width":280-600,"height":320-820,"bg","accent","text"},
|
|
"animations":true|false,
|
|
"customCss":"<plain CSS rules for anything else, e.g. .card{border-width:3px} h1{letter-spacing:4px}>"}
|
|
FONT = system | serif | mono | Inter | Poppins | Montserrat | Nunito | Space Grotesk | Lora | Merriweather |
|
|
Playfair Display | Roboto Slab | Fira Code | Space Mono | VT323 | Press Start 2P | Bebas Neue | Pacifico | Comic Neue
|
|
Relative requests use CURRENT_LOOK (e.g. "bigger text" => raise fontScale from its current value, default 1).
|
|
Light mode usually needs only {"mode":"light"} plus any colours asked for; "green accent" => palette.accent.
|
|
Out of bounds (say so briefly and offer the closest option): the TEST DEMO banner, the agent badge, checkout fee
|
|
lines/amounts and the payment form are fixed for safety; no scripts or HTML; images only from the hosts above.
|
|
For a purely visual request return ONLY set_theme (do not re-show listings), and confirm what changed in "reply".
|
|
PAGE TEXT is editable with set_theme copy (current values are in PAGE_TEXT below). Where things are:
|
|
copy.logo = the brand/site name in the TOP-LEFT corner of the header (a.k.a. logo, brand, site name, header text, "top left").
|
|
copy.headline = the big title, copy.subhead = the line under it (subtitle/tagline), copy.footer = the bottom line,
|
|
copy.chips = the quick-action buttons, copy.bullets = the bullet points (send the whole list when changing one item).
|
|
"replace/change/rename A to/with B": find A in PAGE_TEXT and change that slot (only the words A if A is part of it).
|
|
Keep the user's capitalisation for B (if they typed it all lowercase, match the style of the text it replaces).
|
|
e.g. "replace agent market to new internet on the top left corner" => {"type":"set_theme","payload":{"copy":{"logo":"New Internet"}}}
|
|
|
|
Rules:
|
|
- Never put internal ids (lst_..., pi_..., cash_..., ord_...) in "reply"; refer to items by their title.
|
|
- Interpret needs, not just keywords: e.g. "get my kid to school" => a kids' bike; "garden" => lawn/yard services.
|
|
- Respect budgets (prices in CATALOG are integer cents: 5500 = $55.00).
|
|
- If nothing fits, say so and show the closest options with show_cards.
|
|
- Online payment adds a FEE_PERCENT facilitation fee paid by the buyer; cash in person has no fee. Mention this when relevant.
|
|
- Usually return exactly one main panel action. Keep "reply" short and do not repeat the whole catalog in text.
|
|
- Optional: if you truly need a server action first, add "tools":[{"name":"search","args":{"q":"..."}}] (also
|
|
create_listing, start_checkout, start_cash_deal); you will get TOOL_RESULTS and then answer. Prefer not to.
|
|
""".replace("FEE_PERCENT", f"{FEE_RATE*100:.1f}%")
|
|
|
|
|
|
ALLOWED_ACTIONS = {"show_hero", "show_cards", "show_listing_form", "show_compare",
|
|
"show_checkout", "show_cash_meetup", "show_receipt", "clear_panel", "toast", "set_theme"}
|
|
ID_RE = re.compile(r"\s*\(?\b(?:lst|pi|cash|ord|evt)_[0-9a-f]{6,}\b\)?")
|
|
|
|
|
|
def money(cents: int) -> str:
|
|
return f"${cents/100:.2f}"
|
|
|
|
|
|
def fee_for(amount_cents: int) -> int:
|
|
return int(round(amount_cents * FEE_RATE))
|
|
|
|
|
|
def run_tools(store, tools):
|
|
results = []
|
|
for t in tools or []:
|
|
name = t.get("name")
|
|
args = t.get("args") or {}
|
|
try:
|
|
if name == "search":
|
|
q = (args.get("q") or "").lower().strip()
|
|
kind = args.get("kind") or "any"
|
|
limit = int(args.get("limit") or 8)
|
|
hits = []
|
|
for L in store.all("listings"):
|
|
if L.get("status") != "active":
|
|
continue
|
|
if kind in ("product", "service") and L.get("kind") != kind:
|
|
continue
|
|
blob = " ".join([
|
|
L.get("title", ""), L.get("description", ""), L.get("category", ""),
|
|
" ".join(L.get("tags") or []), L.get("area", ""), L.get("kind", ""),
|
|
]).lower()
|
|
if not q or all(tok in blob for tok in q.split()):
|
|
hits.append(L)
|
|
hits = hits[:limit]
|
|
results.append({"name": name, "ok": True, "listings": hits})
|
|
elif name == "get_listing":
|
|
L = store.get("listings", args.get("id"))
|
|
results.append({"name": name, "ok": bool(L), "listing": L})
|
|
elif name == "create_listing":
|
|
required = ["title", "kind", "price", "description"]
|
|
if any(not args.get(k) and args.get(k) != 0 for k in required):
|
|
results.append({"name": name, "ok": False, "error": "missing_fields"})
|
|
else:
|
|
lid = new_id("lst")
|
|
L = {
|
|
"id": lid, "title": str(args["title"])[:120],
|
|
"kind": "service" if args.get("kind") == "service" else "product",
|
|
"price": max(0, int(args["price"])),
|
|
"currency": "USD", "description": str(args.get("description") or "")[:800],
|
|
"category": str(args.get("category") or "misc")[:40],
|
|
"tags": list(args.get("tags") or [])[:12],
|
|
"emoji": str(args.get("emoji") or "📦")[:4],
|
|
"seller": str(args.get("seller") or "demo-seller")[:60],
|
|
"seller_type": args.get("seller_type") if args.get("seller_type") in ("human", "agent") else "human",
|
|
"area": str(args.get("area") or "Demo Town")[:60],
|
|
"payment_options": [p for p in (args.get("payment_options") or ["online", "cash"]) if p in ("online", "cash")] or ["cash"],
|
|
"status": "active", "test_data": True, "created_at": now(),
|
|
}
|
|
store.put("listings", L)
|
|
store.log("listing_created", {"id": lid})
|
|
results.append({"name": name, "ok": True, "listing": L})
|
|
elif name == "start_checkout":
|
|
L = store.get("listings", args.get("listing_id"))
|
|
if not L:
|
|
results.append({"name": name, "ok": False, "error": "not_found"})
|
|
elif L.get("status") != "active":
|
|
results.append({"name": name, "ok": False, "error": "listing_not_active"})
|
|
elif "online" not in (L.get("payment_options") or []):
|
|
results.append({"name": name, "ok": False, "error": "online_not_available"})
|
|
else:
|
|
qty = max(1, int(args.get("quantity") or 1))
|
|
sub = L["price"] * qty
|
|
fee = fee_for(sub)
|
|
intent = {
|
|
"id": new_id("pi"), "listing_id": L["id"], "title": L["title"],
|
|
"buyer": str(args.get("buyer") or "demo-buyer")[:60],
|
|
"quantity": qty, "subtotal_cents": sub, "fee_cents": fee,
|
|
"fee_bps": FEE_BPS, "total_cents": sub + fee,
|
|
"currency": "USD", "status": "requires_payment",
|
|
"mode": "test", "provider": "mock", "created_at": now(),
|
|
}
|
|
store.put("intents", intent)
|
|
store.log("checkout_started", {"id": intent["id"]})
|
|
results.append({"name": name, "ok": True, "intent": intent, "listing": L})
|
|
elif name == "start_cash_deal":
|
|
L = store.get("listings", args.get("listing_id"))
|
|
if not L:
|
|
results.append({"name": name, "ok": False, "error": "not_found"})
|
|
elif L.get("status") != "active":
|
|
results.append({"name": name, "ok": False, "error": "listing_not_active"})
|
|
elif "cash" not in (L.get("payment_options") or []):
|
|
results.append({"name": name, "ok": False, "error": "cash_not_available"})
|
|
else:
|
|
deal = {
|
|
"id": new_id("cash"), "listing_id": L["id"], "title": L["title"],
|
|
"buyer": str(args.get("buyer") or "demo-buyer")[:60],
|
|
"seller": L.get("seller"), "price_cents": L["price"],
|
|
"fee_cents": 0, "fee_bps": 0, "currency": "USD",
|
|
"place": str(args.get("place") or L.get("area") or "Demo Town square")[:120],
|
|
"time": str(args.get("time") or "this weekend, afternoon")[:80],
|
|
"status": "arranged", "mode": "cash_in_person", "created_at": now(),
|
|
}
|
|
store.put("cash_deals", deal)
|
|
store.log("cash_deal", {"id": deal["id"]})
|
|
results.append({"name": name, "ok": True, "deal": deal, "listing": L})
|
|
elif name == "complete_cash_deal":
|
|
deal = store.get("cash_deals", args.get("deal_id"))
|
|
if not deal:
|
|
results.append({"name": name, "ok": False, "error": "not_found"})
|
|
elif deal["status"] == "completed":
|
|
results.append({"name": name, "ok": True, "deal": deal, "order": store.get("orders", deal.get("order_id"))})
|
|
else:
|
|
order = {"id": new_id("ord"), "listing_id": deal["listing_id"], "title": deal["title"],
|
|
"buyer": deal["buyer"], "seller": deal["seller"], "method": "cash_in_person",
|
|
"subtotal_cents": deal["price_cents"], "fee_cents": 0, "fee_bps": 0,
|
|
"total_cents": deal["price_cents"], "seller_receives_cents": deal["price_cents"],
|
|
"currency": "USD", "status": "completed", "mode": "test", "created_at": now()}
|
|
store.put("orders", order)
|
|
deal.update(status="completed", order_id=order["id"], completed_at=now())
|
|
store.put("cash_deals", deal)
|
|
L = store.get("listings", deal["listing_id"])
|
|
if L and L.get("kind") == "product":
|
|
L["status"] = "sold"; store.put("listings", L)
|
|
store.log("cash_deal_completed", {"id": deal["id"], "order": order["id"]})
|
|
results.append({"name": name, "ok": True, "deal": deal, "order": order})
|
|
else:
|
|
results.append({"name": name, "ok": False, "error": "unknown_tool"})
|
|
except Exception as e:
|
|
results.append({"name": name, "ok": False, "error": str(e)[:200]})
|
|
return results
|
|
|
|
|
|
# ---------- deterministic handlers for clicks, forms and payments (typed messages always go to a model) ----------
|
|
|
|
def _search_q(store, q, kind="any", limit=6):
|
|
return run_tools(store, [{"name": "search", "args": {"q": q, "kind": kind, "limit": limit}}])[0]["listings"]
|
|
|
|
|
|
def featured_listings(store, limit=8):
|
|
"""Landing view: the first active listings, no model involved."""
|
|
hits = _search_q(store, "", limit=limit)
|
|
if not hits:
|
|
return {"reply": "No listings yet. Say what you want to sell and I'll set up the form.", "actions": []}
|
|
return {"reply": f"Here are {len(hits)} listings. Tell me what you're after, or click a card.",
|
|
"actions": [{"type": "show_cards", "payload": {"heading": "Featured listings", "listing_ids": [h["id"] for h in hits]}}]}
|
|
|
|
|
|
def sell_form():
|
|
"""The "Sell something" button: an empty listing form (the user fills it in and presses Publish)."""
|
|
return {"reply": "Fill in the listing form. I'll publish it as test data. The online fee only applies when a buyer pays online.",
|
|
"actions": [{"type": "show_listing_form", "payload": {"defaults": {
|
|
"title": "", "kind": "product", "price": 2500, "description": "", "category": "misc", "area": "Demo Town",
|
|
"payment_options": ["online", "cash"], "emoji": "📦"}}}]}
|
|
|
|
|
|
BUSY_REPLY = "The assistant is busy, please try again in a moment."
|
|
|
|
|
|
def fallback_event(store, ev: dict, actor: str):
|
|
t = ev.get("type")
|
|
lid = ev.get("listing_id")
|
|
if t == "view_listing" and lid:
|
|
L = store.get("listings", lid)
|
|
if L:
|
|
return {"reply": f"{L['title']}: {money(L['price'])}. {L['description']}",
|
|
"actions": [{"type": "show_cards", "payload": {"heading": "Listing details", "listing_ids": [lid]}}]}
|
|
if t == "buy_online" and lid:
|
|
res = run_tools(store, [{"name": "start_checkout", "args": {"listing_id": lid, "buyer": actor}}])[0]
|
|
if not res["ok"]:
|
|
return {"reply": f"Online checkout isn't available for this one ({res['error']}). Try cash in person.", "actions": []}
|
|
it = res["intent"]
|
|
return {"reply": (f"Checkout ready. {money(it['subtotal_cents'])} + {FEE_RATE*100:.1f}% facilitation fee "
|
|
f"{money(it['fee_cents'])} = {money(it['total_cents'])}. TEST MODE: card 4242 4242 4242 4242."),
|
|
"actions": [{"type": "show_checkout", "payload": {"listing_id": lid, "intent_id": it["id"]}}]}
|
|
if t == "buy_cash" and lid:
|
|
res = run_tools(store, [{"name": "start_cash_deal", "args": {"listing_id": lid, "buyer": actor}}])[0]
|
|
if not res["ok"]:
|
|
return {"reply": f"Cash isn't offered for this one ({res['error']}). You can pay online instead.", "actions": []}
|
|
d = res["deal"]
|
|
return {"reply": f"Cash meetup arranged. No fee: you pay the seller {money(d['price_cents'])} in person.",
|
|
"actions": [{"type": "show_cash_meetup", "payload": {"listing_id": lid, "deal_id": d["id"]}}]}
|
|
if t == "compare":
|
|
ids = [i for i in (ev.get("listing_ids") or []) if store.get("listings", i)][:4]
|
|
if len(ids) >= 2:
|
|
return {"reply": f"Here's a side-by-side of {len(ids)} listings, including what each costs online vs cash.",
|
|
"actions": [{"type": "show_compare", "payload": {"listing_ids": ids, "heading": "Compare"}}]}
|
|
return {"reply": "Pick at least two listings to compare.", "actions": []}
|
|
if t == "sell":
|
|
return sell_form()
|
|
if t == "submit_listing":
|
|
f = ev.get("fields") or {}
|
|
try:
|
|
price = int(round(float(str(f.get("price", "0")).replace("$", "")) * 100))
|
|
except ValueError:
|
|
price = -1
|
|
if not f.get("title") or not f.get("description") or price < 0:
|
|
return {"reply": "The listing needs a title, a description and a price.",
|
|
"actions": [{"type": "show_listing_form", "payload": {"defaults": f}}]}
|
|
args = {**f, "price": price, "seller": actor, "seller_type": "human"}
|
|
res = run_tools(store, [{"name": "create_listing", "args": args}])[0]
|
|
if not res["ok"]:
|
|
return {"reply": f"Couldn't create it: {res.get('error')}", "actions": []}
|
|
L = res["listing"]
|
|
return {"reply": f"Published “{L['title']}” at {money(L['price'])} (test listing).",
|
|
"actions": [{"type": "toast", "payload": {"level": "ok", "text": "Listing published"}},
|
|
{"type": "show_cards", "payload": {"heading": "Your new listing", "listing_ids": [L["id"]]}}]}
|
|
if t == "payment_result":
|
|
oid = ev.get("order_id")
|
|
if ev.get("status") == "succeeded" and oid and store.get("orders", oid):
|
|
o = store.get("orders", oid)
|
|
return {"reply": f"Test payment succeeded. You paid {money(o['total_cents'])} including a {money(o['fee_cents'])} fee.",
|
|
"actions": [{"type": "show_receipt", "payload": {"order_id": oid}}]}
|
|
return {"reply": "The test payment didn't go through. Use card 4242 4242 4242 4242, or choose cash in person.",
|
|
"actions": [{"type": "toast", "payload": {"level": "warn", "text": "Payment declined (test)"}}]}
|
|
if t == "cash_completed":
|
|
res = run_tools(store, [{"name": "complete_cash_deal", "args": {"deal_id": ev.get("deal_id")}}])[0]
|
|
if res["ok"]:
|
|
o = res["order"]
|
|
return {"reply": f"Cash deal marked complete. Fee $0.00; the seller keeps all {money(o['total_cents'])}.",
|
|
"actions": [{"type": "show_receipt", "payload": {"order_id": o["id"]}}]}
|
|
return {"reply": "Couldn't find that cash deal.", "actions": []}
|
|
return {"reply": "I didn't recognise that action.", "actions": []}
|
|
|
|
|
|
# ---------- LLM path ----------
|
|
|
|
LLM_TIMEOUT = float(os.environ.get("AGENT_MARKET_LLM_TIMEOUT", "12")) # hard cap per model attempt
|
|
LLM_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_BUDGET", "28")) # hard cap across the whole chain
|
|
_pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="llm")
|
|
COOLDOWN_429 = float(os.environ.get("AGENT_MARKET_LLM_COOLDOWN", "60"))
|
|
_cooldown = {} # model -> unix time until which we skip it after a 429
|
|
LAST = {"model": None, "at": None, "error": None, "local": False}
|
|
QUOTA = {"until": 0}
|
|
# Local model (llama.cpp llama-server on this box, OpenAI-compatible): the last link of the chain, after the hosted models.
|
|
LOCAL_TIMEOUT = float(os.environ.get("AGENT_MARKET_LOCAL_LLM_TIMEOUT", "45")) # its own cap (CPU inference is slower)
|
|
LOCAL_MIN = float(os.environ.get("AGENT_MARKET_LOCAL_LLM_MIN", "25")) # always give the local model at least this long
|
|
TOTAL_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_TOTAL_BUDGET", "60")) # hosted chain + local model, per turn
|
|
_local_slots = threading.Semaphore(int(os.environ.get("AGENT_MARKET_LOCAL_LLM_CONCURRENCY", "2"))) # 1 running + 1 queued
|
|
_QUOTA_FILE = os.path.join(os.environ["AGENT_MARKET_DATA"], "llm_quota.json") if os.environ.get("AGENT_MARKET_DATA") else None
|
|
|
|
|
|
def _load_quota():
|
|
"""Remember a hosted daily-quota stop across restarts, so a redeploy doesn't spend a call to rediscover it."""
|
|
try:
|
|
with open(_QUOTA_FILE) as f:
|
|
d = json.load(f)
|
|
until = min(float(d.get("until") or 0), time.time() + 36 * 3600) # a daily cap never lasts longer than this
|
|
if until > time.time() and d.get("base") == os.environ.get("AGENT_MARKET_LLM_BASE_URL", ""):
|
|
QUOTA["until"] = until
|
|
except (OSError, ValueError, TypeError, AttributeError):
|
|
pass
|
|
|
|
|
|
def _save_quota():
|
|
if not _QUOTA_FILE:
|
|
return
|
|
try:
|
|
with open(_QUOTA_FILE, "w") as f:
|
|
json.dump({"until": QUOTA["until"], "base": os.environ.get("AGENT_MARKET_LLM_BASE_URL", "")}, f)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
_load_quota()
|
|
|
|
|
|
class LLMError(Exception):
|
|
def __init__(self, kind, detail=""):
|
|
super().__init__(f"{kind}: {detail}")
|
|
self.kind = kind
|
|
|
|
|
|
def _read_key():
|
|
"""Key from a file path (preferred) or env. Never logged, never returned to clients."""
|
|
path = os.environ.get("AGENT_MARKET_LLM_API_KEY_FILE")
|
|
if path:
|
|
try:
|
|
with open(os.path.expanduser(path)) as f:
|
|
k = f.read().strip()
|
|
if k:
|
|
return k
|
|
except OSError:
|
|
pass
|
|
for env in ("AGENT_MARKET_LLM_API_KEY", "XAI_API_KEY", "OPENAI_API_KEY"):
|
|
if os.environ.get(env):
|
|
return os.environ[env]
|
|
return None
|
|
|
|
|
|
def _local_config():
|
|
"""Local OpenAI-compatible server (no key), e.g. AGENT_MARKET_LOCAL_LLM_URL=http://127.0.0.1:11434/v1."""
|
|
base = (os.environ.get("AGENT_MARKET_LOCAL_LLM_URL") or "").strip()
|
|
model = (os.environ.get("AGENT_MARKET_LOCAL_LLM_MODEL") or "").strip()
|
|
if not base or not model:
|
|
return None
|
|
return {"provider": "local", "url": base.rstrip("/") + "/chat/completions", "model": model, "models": [model], "local": True}
|
|
|
|
|
|
def _llm_config():
|
|
"""Hosted LLM config (OpenAI-compatible chat completions) with an ordered model fallback chain,
|
|
plus an optional local backup model. None when neither is configured."""
|
|
remote = None
|
|
if _read_key():
|
|
base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or ""
|
|
if not base:
|
|
base = "https://api.x.ai/v1" if os.environ.get("XAI_API_KEY") else ("https://api.openai.com/v1" if os.environ.get("OPENAI_API_KEY") else "")
|
|
models = [m.strip() for m in (os.environ.get("AGENT_MARKET_LLM_MODELS") or os.environ.get("AGENT_MARKET_LLM_MODEL") or "").split(",") if m.strip()]
|
|
if base and models:
|
|
remote = {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions",
|
|
"models": models, "model": models[0]}
|
|
local = _local_config()
|
|
if not remote and not local:
|
|
return None
|
|
cfg = remote or {"provider": "local", "url": None, "models": [], "model": local["model"]}
|
|
cfg["local"] = local
|
|
return cfg
|
|
|
|
|
|
# GBNF for "exactly one JSON object" (llama.cpp grammar syntax). Used instead of response_format for the local model:
|
|
# it is cheap to sample with and the object ends at the closing brace (no trailing whitespace run).
|
|
JSON_GRAMMAR = r'''root ::= "{" ws ( string ":" ws value ("," ws string ":" ws value)* )? "}"
|
|
value ::= object | array | string | number | ("true" | "false" | "null") ws
|
|
object ::= "{" ws ( string ":" ws value ("," ws string ":" ws value)* )? "}" ws
|
|
array ::= "[" ws ( value ("," ws value)* )? "]" ws
|
|
string ::= "\"" ( [^"\\\x7F\x00-\x1F] | "\\" (["\\bfnrt] | "u" [0-9a-fA-F]{4}) )* "\"" ws
|
|
number ::= ("-"? ([0-9] | [1-9] [0-9]{0,15})) ("." [0-9]+)? ([eE] [-+]? [0-9]{1,3})? ws
|
|
ws ::= | " " | "\n" [ ]{0,12}
|
|
'''
|
|
|
|
|
|
def _call_llm(cfg, model, messages, json_mode=True, timeout=None):
|
|
payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100}
|
|
headers = {"Content-Type": "application/json", "X-Title": "Agent Market demo"}
|
|
if cfg.get("local") is True: # the local server itself (a hosted cfg carries its local backup as a dict)
|
|
payload["max_tokens"] = 700
|
|
if json_mode:
|
|
payload["grammar"] = JSON_GRAMMAR # llama-server: constrain the output to one JSON object
|
|
else:
|
|
if json_mode:
|
|
payload["response_format"] = {"type": "json_object"}
|
|
headers["Authorization"] = "Bearer " + _read_key()
|
|
req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers=headers)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout or LLM_TIMEOUT) as r:
|
|
data = json.loads(r.read().decode())
|
|
except urllib.error.HTTPError as e:
|
|
body = e.read().decode(errors="replace")[:2000]
|
|
if e.code == 429:
|
|
m = re.search(r'"X-RateLimit-Reset":\s*"?(\d{12,})', body)
|
|
if "per-day" in body or "daily" in body:
|
|
raise LLMError("daily_quota", m.group(1) if m else "")
|
|
raise LLMError("rate_limited", body[:300])
|
|
if e.code == 400 and json_mode and ("response_format" in body or "grammar" in body or "sampler" in body):
|
|
return _call_llm(cfg, model, messages, json_mode=False, timeout=timeout)
|
|
raise LLMError(f"http_{e.code}", body[:300])
|
|
except (urllib.error.URLError, TimeoutError, OSError) as e:
|
|
if isinstance(e, TimeoutError) or "timed out" in str(e):
|
|
raise LLMError("timeout", type(e).__name__)
|
|
raise LLMError("timeout_or_network", type(e).__name__)
|
|
if isinstance(data, dict) and data.get("error"):
|
|
err = data["error"]
|
|
code = err.get("code") if isinstance(err, dict) else None
|
|
raise LLMError("rate_limited" if code == 429 else "provider_error", str(err)[:200])
|
|
try:
|
|
return data["choices"][0]["message"].get("content") or ""
|
|
except (KeyError, IndexError, TypeError):
|
|
raise LLMError("bad_response", str(data)[:200])
|
|
|
|
|
|
def _parse_agent_json(text: str):
|
|
text = re.sub(r"<think>.*?</think>", "", text or "", flags=re.S).strip()
|
|
text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
|
|
try:
|
|
obj = json.loads(text)
|
|
except ValueError:
|
|
m = re.search(r"\{.*\}", text, re.S)
|
|
if not m:
|
|
raise LLMError("not_json", text[:120])
|
|
try:
|
|
obj = json.loads(m.group(0))
|
|
except ValueError:
|
|
raise LLMError("not_json", text[:120])
|
|
if not isinstance(obj, dict):
|
|
raise LLMError("not_json", "top-level not an object")
|
|
return obj
|
|
|
|
|
|
def _catalog(store):
|
|
return [{"id": L["id"], "title": L["title"], "kind": L["kind"], "price_cents": L["price"],
|
|
"category": L.get("category"), "about": (L.get("description") or "")[:110],
|
|
"seller_type": L.get("seller_type"), "pay": L.get("payment_options")}
|
|
for L in store.all("listings") if L.get("status") == "active"][:60]
|
|
|
|
|
|
def _page_text(look):
|
|
from .copyedit import current_copy
|
|
try:
|
|
t = json.loads(look) if look else {}
|
|
except Exception:
|
|
t = {}
|
|
c = current_copy(t if isinstance(t, dict) else {}, FEE_BPS)
|
|
return json.dumps({"logo": c["logo"], "headline": c["headline"], "subhead": c["subhead"][:120], "footer": c["footer"],
|
|
"chips": [x.get("label") for x in c["chips"]], "bullets": c["bullets"]}, ensure_ascii=False)
|
|
|
|
|
|
LOCAL_EXAMPLES = """
|
|
EXAMPLES (visitor message => your whole answer). Follow the pattern; use the visitor's own words and CATALOG ids.
|
|
"change the name in the top left to Bazaar Hub" => {"reply":"Done: the top-left logo now says Bazaar Hub.","actions":[{"type":"set_theme","payload":{"copy":{"logo":"Bazaar Hub"}}}]}
|
|
"swap Agent Market for Corner Shop in the header" => {"reply":"The header now says Corner Shop.","actions":[{"type":"set_theme","payload":{"copy":{"logo":"Corner Shop"}}}]}
|
|
"make the big title say Hello neighbours" => {"reply":"Headline updated.","actions":[{"type":"set_theme","payload":{"copy":{"headline":"Hello neighbours"}}}]}
|
|
"get rid of the bullet points" => {"reply":"Bullet points hidden.","actions":[{"type":"set_theme","payload":{"sections":{"hidden":["bullets"]}}}]}
|
|
"go back to the original look" => {"reply":"Back to the default look.","actions":[{"type":"set_theme","payload":{"reset":true}}]}
|
|
"light theme please, with an orange accent" => {"reply":"Light mode with an orange accent.","actions":[{"type":"set_theme","payload":{"mode":"light","palette":{"accent":"#f76b15"}}}]}
|
|
"one item per row instead of a grid" => {"reply":"Results now show as a list.","actions":[{"type":"set_theme","payload":{"layout":"list"}}]}
|
|
"put the chat on the left side" => {"reply":"Chat moved to the left.","actions":[{"type":"set_theme","payload":{"chat":{"position":"left"}}}]}
|
|
"use a mono font, slightly larger" => {"reply":"Mono font, a bit larger.","actions":[{"type":"set_theme","payload":{"font":"mono","fontScale":1.15}}]}
|
|
"a teal to purple gradient behind everything" => {"reply":"Teal to purple gradient background.","actions":[{"type":"set_theme","payload":{"background":{"kind":"gradient","colors":["#12a594","#8e4ec6"],"angle":135}}}]}
|
|
"make it neon" => {"reply":"Neon look applied.","actions":[{"type":"set_theme","payload":{"preset":"neon"}}]}
|
|
"I'd like to sell my old sofa for $60, cash only" => {"reply":"I've filled in the listing form. Check it and press Publish.","actions":[{"type":"show_listing_form","payload":{"defaults":{"title":"Old sofa","kind":"product","price":6000,"description":"Used sofa in good condition.","category":"home","area":"Demo Town","payment_options":["cash"]}}}]}
|
|
"<a search>" => {"reply":"<one short line>","actions":[{"type":"show_cards","payload":{"heading":"<what was searched>","listing_ids":["<only CATALOG ids that match>"]}}]}
|
|
Only change what the visitor asked for. A visual or text request returns set_theme only (no listings).
|
|
"""
|
|
|
|
|
|
def _system_prompt(store, look="", local=False):
|
|
# stable parts first (schema, examples, catalog) so a local model's prompt cache can reuse them across turns
|
|
return (UI_SCHEMA + (LOCAL_EXAMPLES if local else "") + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000] +
|
|
"\nCURRENT_LOOK (this visitor's theme overrides; {} = default dark look):\n" + (look or "{}") +
|
|
"\nPAGE_TEXT (what the editable text slots show right now):\n" + _page_text(look))
|
|
|
|
|
|
_PRIMED = {"key": None, "decodes": None}
|
|
|
|
|
|
def _local_restarted(local):
|
|
"""True when the local server is down or was restarted since we last looked (its llama-server /metrics counter went
|
|
back down), so its prompt cache is cold again and must be re-primed. Unknown servers (no /metrics) -> False."""
|
|
root = local["url"].split("/v1/")[0]
|
|
try:
|
|
with urllib.request.urlopen(root + "/metrics", timeout=2) as r:
|
|
m = re.search(r"^llamacpp:n_decode_total (\d+)", r.read().decode(errors="replace"), re.M)
|
|
except urllib.error.HTTPError:
|
|
return False
|
|
except Exception:
|
|
_PRIMED["decodes"] = None
|
|
return True
|
|
if not m:
|
|
return False
|
|
n, last = int(m.group(1)), _PRIMED["decodes"]
|
|
_PRIMED["decodes"] = n
|
|
return last is not None and n < last
|
|
|
|
|
|
def _prime_local_once(store):
|
|
"""Process the current system prompt on the local model in the background (1 output token), so the next visitor
|
|
turn reuses llama-server's prompt cache instead of paying ~30 s of CPU prompt processing."""
|
|
local = _local_config()
|
|
if not local:
|
|
return False
|
|
if _local_restarted(local):
|
|
_PRIMED["key"] = None
|
|
sp = _system_prompt(store, "", local=True)
|
|
key = hash(sp)
|
|
if key == _PRIMED["key"] or not _local_slots.acquire(blocking=False):
|
|
return False
|
|
try:
|
|
body = {"model": local["model"], "messages": [{"role": "system", "content": sp}, {"role": "user", "content": _visitor("hi")}],
|
|
"max_tokens": 1, "temperature": 0}
|
|
req = urllib.request.Request(local["url"], data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
|
|
with urllib.request.urlopen(req, timeout=180) as r:
|
|
r.read()
|
|
_PRIMED["key"] = key
|
|
_local_restarted(local) # remember the counter after our own call
|
|
return True
|
|
except Exception:
|
|
return False
|
|
finally:
|
|
_local_slots.release()
|
|
|
|
|
|
def start_local_primer(store, every=30):
|
|
"""Background thread: prime at start-up, whenever the catalog (and so the prompt) changes, and after the local server
|
|
restarts. Between those it only reads /metrics every 30 s, so an idle server stays idle."""
|
|
if not _local_config() or os.environ.get("AGENT_MARKET_LOCAL_LLM_PRIME", "1") != "1":
|
|
return
|
|
|
|
def loop():
|
|
while True:
|
|
try:
|
|
_prime_local_once(store)
|
|
except Exception:
|
|
pass
|
|
time.sleep(every)
|
|
threading.Thread(target=loop, name="local-llm-primer", daemon=True).start()
|
|
|
|
|
|
def _visitor(message):
|
|
# small models (and chat templates that fold the system prompt into the first user turn) need a clear boundary
|
|
return "\n\nVISITOR MESSAGE (answer this one with the JSON object):\n" + message
|
|
|
|
|
|
def _llm_once(store, cfg, model, message, history, look="", timeout=None):
|
|
local = cfg.get("local") is True
|
|
sys_prompt = _system_prompt(store, look, local=local)
|
|
msgs = [{"role": "system", "content": sys_prompt}]
|
|
for h in (history or [])[-6:]:
|
|
if h.get("role") in ("user", "assistant") and h.get("content"):
|
|
msgs.append({"role": h["role"], "content": str(h["content"])[:600]})
|
|
msgs.append({"role": "user", "content": _visitor(message) if local else message})
|
|
t_end = time.time() + (timeout or LLM_TIMEOUT)
|
|
content = _call_llm(cfg, model, msgs, timeout=timeout)
|
|
plan = _parse_agent_json(content)
|
|
tool_results = []
|
|
tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3]
|
|
if tools:
|
|
tool_results = run_tools(store, tools)
|
|
msgs += [{"role": "assistant", "content": content},
|
|
{"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] +
|
|
"\nNow return the final {\"reply\",\"actions\"} JSON only."}]
|
|
left = t_end - time.time()
|
|
if left < 2:
|
|
raise LLMError("timeout", "no time left for the tool round")
|
|
plan = _parse_agent_json(_call_llm(cfg, model, msgs, timeout=left))
|
|
actions = [a for a in (plan.get("actions") or []) if isinstance(a, dict) and a.get("type") in ALLOWED_ACTIONS]
|
|
reply = str(plan.get("reply") or "").strip()
|
|
if not reply and not actions:
|
|
raise LLMError("empty_plan", "")
|
|
reply = ID_RE.sub("", reply).strip()
|
|
return {"reply": (reply or "Done.")[:600], "actions": actions, "tools": tool_results}
|
|
|
|
|
|
_STOP = set("show me find search look looking for want need some any the a an and or with under over below above cheap "
|
|
"please can could you i my our get buy see list all everything something anything thing things stuff near "
|
|
"options option there here have has what which that this from about like".split())
|
|
|
|
|
|
def _focus_results(store, message, actions):
|
|
"""Small local models sometimes pad show_cards with unrelated listings. If some chosen listings share a keyword with
|
|
the request and others share none, keep only the matching ones (semantic picks with no keyword overlap stay)."""
|
|
words = {w.rstrip("s") for w in re.findall(r"[a-z]{3,}", (message or "").lower()) if w not in _STOP}
|
|
if not words:
|
|
return actions
|
|
for a in actions:
|
|
if a.get("type") != "show_cards" or not isinstance(a.get("payload"), dict):
|
|
continue
|
|
ids = [i for i in (a["payload"].get("listing_ids") or []) if isinstance(i, str)]
|
|
def hit(i):
|
|
L = store.get("listings", i) or {}
|
|
text = " ".join([L.get("title", ""), L.get("category") or "", " ".join(L.get("tags") or []), L.get("description", "")]).lower()
|
|
toks = {t.rstrip("s") for t in re.findall(r"[a-z]{3,}", text)}
|
|
return bool(words & toks)
|
|
keep = [i for i in ids if hit(i)]
|
|
if keep and len(keep) < len(ids):
|
|
a["payload"]["listing_ids"] = keep
|
|
return actions
|
|
|
|
|
|
LOCAL_HISTORY = int(os.environ.get("AGENT_MARKET_LOCAL_LLM_HISTORY", "0")) # small models copy earlier turns; 0 = no history
|
|
|
|
|
|
def llm_agent(store, message, history, cfg, look=""):
|
|
"""Hosted models in order (skipping ones cooling down after a 429, and all of them while the daily free quota is
|
|
used up), then the local model with its own timeout. Raises if everything fails (the visitor gets BUSY_REPLY)."""
|
|
errors = []
|
|
now_t = time.time()
|
|
start = now_t
|
|
local = cfg.get("local")
|
|
if QUOTA["until"] > now_t:
|
|
chain = []
|
|
errors.append({"model": "*", "error": "daily_quota", "ms": 0})
|
|
else:
|
|
chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t]
|
|
if not chain and not local:
|
|
chain = cfg["models"][-1:]
|
|
for model in chain:
|
|
t0 = time.time()
|
|
remaining = LLM_BUDGET - (t0 - start)
|
|
if remaining < 3:
|
|
errors.append({"model": model, "error": "skipped_budget", "ms": 0})
|
|
continue
|
|
try:
|
|
fut = _pool.submit(_llm_once, store, cfg, model, message, history, look)
|
|
try:
|
|
out = fut.result(timeout=min(LLM_TIMEOUT, remaining))
|
|
except concurrent.futures.TimeoutError:
|
|
raise LLMError("timeout", f">{min(LLM_TIMEOUT, remaining):.0f}s")
|
|
out.update(agent="llm", provider=cfg["provider"], model=model,
|
|
latency_ms=int((time.time() - t0) * 1000), fallbacks=errors)
|
|
LAST.update(model=model, at=time.time(), error=None, local=False)
|
|
return out
|
|
except LLMError as e:
|
|
if e.kind == "rate_limited":
|
|
_cooldown[model] = time.time() + COOLDOWN_429
|
|
elif e.kind == "daily_quota":
|
|
# the free-tier daily cap is per key: park every model until the reset (or 15 min if unknown)
|
|
d = str(e).split(": ", 1)[-1]
|
|
until = int(d) / 1000 if d.isdigit() else time.time() + 900
|
|
for mm in cfg["models"]:
|
|
_cooldown[mm] = max(_cooldown.get(mm, 0), until)
|
|
QUOTA["until"] = until
|
|
_save_quota()
|
|
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
|
|
break
|
|
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
|
|
except Exception as e: # never let the page break
|
|
errors.append({"model": model, "error": type(e).__name__, "ms": int((time.time() - t0) * 1000)})
|
|
if local:
|
|
t0 = time.time()
|
|
cap = min(LOCAL_TIMEOUT, max(LOCAL_MIN, TOTAL_BUDGET - (t0 - start)))
|
|
if not _local_slots.acquire(blocking=False):
|
|
errors.append({"model": local["model"], "error": "local_busy", "ms": 0})
|
|
else:
|
|
try:
|
|
hist = (history or [])[-LOCAL_HISTORY:] if LOCAL_HISTORY > 0 else []
|
|
fut = _pool.submit(_llm_once, store, local, local["model"], message, hist, look, cap)
|
|
except Exception:
|
|
_local_slots.release()
|
|
raise
|
|
fut.add_done_callback(lambda f: _local_slots.release()) # free the slot when the call really ends
|
|
try:
|
|
try:
|
|
out = fut.result(timeout=cap + 1)
|
|
except concurrent.futures.TimeoutError:
|
|
raise LLMError("timeout", f">{cap:.0f}s")
|
|
out["actions"] = _focus_results(store, message, out["actions"])
|
|
out.update(agent="llm", provider="local", local=True, model=local["model"],
|
|
latency_ms=int((time.time() - t0) * 1000), fallbacks=errors)
|
|
LAST.update(model=local["model"], at=time.time(), error=None, local=True)
|
|
return out
|
|
except LLMError as e:
|
|
errors.append({"model": local["model"], "error": e.kind, "ms": int((time.time() - t0) * 1000)})
|
|
except Exception as e:
|
|
errors.append({"model": local["model"], "error": type(e).__name__, "ms": int((time.time() - t0) * 1000)})
|
|
LAST.update(error=errors[-1]["error"] if errors else "no_model", at=time.time())
|
|
raise LLMError("all_models_failed", json.dumps(errors))
|
|
|
|
|
|
# Structured UI events (button clicks, form submits, payment results) have one obvious outcome,
|
|
# so they are handled deterministically: fast, reliable, and they don't spend free-tier LLM quota.
|
|
# Free-text messages go to the LLM. Set AGENT_MARKET_LLM_EVENTS=1 to route events to the LLM too.
|
|
LLM_EVENTS = os.environ.get("AGENT_MARKET_LLM_EVENTS") == "1"
|
|
|
|
|
|
def busy_reply(errors=None, err="no_model", ms=0):
|
|
"""Every model failed (or none is configured): say so honestly. Typed messages never get rule-based answers."""
|
|
return {"reply": BUSY_REPLY, "actions": [], "agent": "unavailable", "busy": True, "retry": True,
|
|
"llm_error": err, "fallbacks": errors or [], "latency_ms": ms}
|
|
|
|
|
|
def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer", look=""):
|
|
actor = (actor or "demo-buyer")[:60]
|
|
t0 = time.time()
|
|
if event and (event.get("type") == "landing" or not LLM_EVENTS):
|
|
out = featured_listings(store) if event.get("type") == "landing" else fallback_event(store, event, actor)
|
|
out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000))
|
|
return out
|
|
cfg = _llm_config()
|
|
if not cfg:
|
|
if event: # AGENT_MARKET_LLM_EVENTS=1 but no model: clicks still work deterministically
|
|
out = fallback_event(store, event, actor)
|
|
out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000))
|
|
return out
|
|
return busy_reply(err="no_model_configured", ms=int((time.time() - t0) * 1000))
|
|
text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
|
|
try:
|
|
return llm_agent(store, text, history or [], cfg, look)
|
|
except Exception as e:
|
|
kind = getattr(e, "kind", type(e).__name__)
|
|
errors = json.loads(str(e).split(": ", 1)[1]) if kind == "all_models_failed" else []
|
|
if event:
|
|
out = fallback_event(store, event, actor)
|
|
out.update(agent="ui-event", llm_error=kind, fallbacks=errors, latency_ms=int((time.time() - t0) * 1000))
|
|
return out
|
|
return busy_reply(errors, kind, int((time.time() - t0) * 1000))
|
|
|
|
|
|
_LOCAL_PING = {"at": 0, "ok": None}
|
|
|
|
|
|
def _local_up(local):
|
|
"""Is the local model server answering? (cached 15 s; loopback only)"""
|
|
if time.time() - _LOCAL_PING["at"] > 15:
|
|
try:
|
|
with urllib.request.urlopen(local["url"].rsplit("/chat/completions", 1)[0] + "/models", timeout=1.5) as r:
|
|
_LOCAL_PING["ok"] = r.status == 200
|
|
except Exception:
|
|
_LOCAL_PING["ok"] = False
|
|
_LOCAL_PING["at"] = time.time()
|
|
return _LOCAL_PING["ok"]
|
|
|
|
|
|
def agent_status():
|
|
cfg = _llm_config()
|
|
if cfg:
|
|
now_t = time.time()
|
|
local = cfg.get("local")
|
|
return {"mode": "llm", "provider": cfg["provider"], "model": (cfg["models"] or [None])[0] or (local or {}).get("model"),
|
|
"chain": cfg["models"] + ([f"local:{local['model']}"] if local else []),
|
|
"local_model": local["model"] if local else None, "local_ok": _local_up(local) if local else None,
|
|
"cooling_down": [m for m in cfg["models"] if _cooldown.get(m, 0) > now_t],
|
|
"last_model": LAST["model"], "last_local": LAST["local"], "last_error": LAST["error"],
|
|
"ui_events": "llm" if LLM_EVENTS else "deterministic",
|
|
"daily_quota_exhausted_until_utc": time.strftime("%Y-%m-%dT%H:%MZ", time.gmtime(QUOTA["until"])) if QUOTA["until"] > now_t else None}
|
|
return {"mode": "unavailable", "provider": None, "model": None, "chain": [],
|
|
"note": "No model configured (set AGENT_MARKET_LLM_API_KEY_FILE + AGENT_MARKET_LLM_BASE_URL + AGENT_MARKET_LLM_MODELS "
|
|
"and/or AGENT_MARKET_LOCAL_LLM_URL + AGENT_MARKET_LOCAL_LLM_MODEL). Typed messages get the busy reply; "
|
|
"clicks, forms and payments still work."}
|