OpenRouter free-model LLM: key from file, model fallback chain with 429 cooldown and hard deadlines, per-IP chat rate limit, model badge, deterministic UI events

This commit is contained in:
leora 2026-10-08 19:28:14 +08:00
parent 6e60dfd6f9
commit b1a43c897c
6 changed files with 278 additions and 133 deletions

View File

@ -15,6 +15,13 @@ mkdir -p "$ROOT"
if [ ! -d "$ROOT/app/.git" ]; then git clone -q "$REPO" "$ROOT/app"; fi if [ ! -d "$ROOT/app/.git" ]; then git clone -q "$REPO" "$ROOT/app"; fi
cd "$ROOT/app" && git fetch -q origin && git checkout -q --detach "origin/$REF" 2>/dev/null || git checkout -q --detach "$REF" cd "$ROOT/app" && git fetch -q origin && git checkout -q --detach "origin/$REF" 2>/dev/null || git checkout -q --detach "$REF"
echo "deploying $ENVN @ $(git rev-parse --short HEAD) on :$PORT" echo "deploying $ENVN @ $(git rev-parse --short HEAD) on :$PORT"
# Optional config outside the repo (never committed): shared LLM settings, then per-env overrides.
# llm.env holds e.g. AGENT_MARKET_LLM_API_KEY_FILE / _BASE_URL / _MODELS (a path to the key, never the key itself).
CONF=$HOME/.config/agent-market
set -a
[ -f "$CONF/llm.env" ] && . "$CONF/llm.env"
[ -f "$CONF/$ENVN.env" ] && . "$CONF/$ENVN.env"
set +a
PID=$(ss -ltnp 2>/dev/null | grep ":$PORT " | grep -o 'pid=[0-9]*' | cut -d= -f2 || true) PID=$(ss -ltnp 2>/dev/null | grep ":$PORT " | grep -o 'pid=[0-9]*' | cut -d= -f2 || true)
[ -n "$PID" ] && kill "$PID" && sleep 0.5 [ -n "$PID" ] && kill "$PID" && sleep 0.5
AGENT_MARKET_PORT=$PORT AGENT_MARKET_ENV=$ENVN AGENT_MARKET_DATA=$ROOT/data \ AGENT_MARKET_PORT=$PORT AGENT_MARKET_ENV=$ENVN AGENT_MARKET_DATA=$ROOT/data \

View File

@ -4,56 +4,40 @@ Returns {reply, actions} where actions is a list of UI action objects from a fix
component set the frontend knows how to render. component set the frontend knows how to render.
""" """
from __future__ import annotations from __future__ import annotations
import json, os, re, urllib.request, urllib.error import concurrent.futures, json, os, re, time, urllib.request, urllib.error
from .store import new_id, now from .store import new_id, now
FEE_BPS = int(os.environ.get("AGENT_MARKET_FEE_BPS", "500")) # 5.00% FEE_BPS = int(os.environ.get("AGENT_MARKET_FEE_BPS", "500")) # 5.00%
FEE_RATE = FEE_BPS / 10000.0 FEE_RATE = FEE_BPS / 10000.0
UI_SCHEMA = """ UI_SCHEMA = """
You are Market Guide, the concierge for Agent Market (a TEST DEMO marketplace). You are Market Guide, the AI agent that drives the page of Agent Market, a TEST DEMO marketplace
You control the page by returning JSON UI actions the frontend renders. where people and AI agents buy and sell products and services. All listings are fake test data and
Never invent IDs. Only use listing ids from SEARCH_RESULTS or CREATE_LISTING results. all payments are TEST MODE ONLY (no real money).
Currency amounts are integer cents. Always mention that payments are TEST MODE ONLY.
Online purchases add a facilitation fee of FEE_PERCENT. Cash-in-person deals have $0 fee.
Reply ONLY with a single JSON object of this shape (no markdown fences): 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 helpful message to the user", {"reply": "<short friendly message, max 60 words>", "actions": [ ... ]}
"actions": [ /* 0 or more of the actions below */ ]
}
Allowed actions (pick only from this set): Allowed actions (use only these types; listing ids must come from CATALOG below, never invent them):
1. {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} - {"type":"show_cards","payload":{"heading":"...","listing_ids":["lst_..."]}} list matching items
2. {"type":"show_cards","payload":{"heading":"...","listing_ids":["lst_..."]}} - {"type":"show_compare","payload":{"heading":"...","listing_ids":["lst_...","lst_..."]}} side-by-side, 2-4 ids
3. {"type":"show_listing_form","payload":{"defaults":{"title":"","kind":"product","price":0,"description":"","category":"","area":"","payment_options":["online","cash"]}}} - {"type":"show_checkout","payload":{"listing_id":"lst_..."}} online purchase; the panel shows price + fee
4. {"type":"show_compare","payload":{"listing_ids":["lst_...","lst_..."],"heading":"..."}} - {"type":"show_cash_meetup","payload":{"listing_id":"lst_...","suggested_place":"...","suggested_time":"..."}} pay cash in person, $0 fee
5. {"type":"show_checkout","payload":{"listing_id":"lst_...","quantity":1}} - {"type":"show_listing_form","payload":{"defaults":{"title":"...","kind":"product|service","price":<integer cents>,"description":"...","category":"...","area":"Demo Town","payment_options":["online","cash"]}}}
6. {"type":"show_cash_meetup","payload":{"listing_id":"lst_...","suggested_place":"...","suggested_time":"..."}} use when the user wants to sell/list something; prefill everything you can infer, the user presses Publish
7. {"type":"show_receipt","payload":{"order_id":"ord_..."}} ("cash only" => payment_options ["cash"]; "$120" => price 12000)
9. {"type":"clear_panel","payload":{}} - {"type":"show_hero","payload":{"title":"...","subtitle":"...","bullets":["..."]}} welcome / help
8. {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}} - {"type":"toast","payload":{"level":"info|ok|warn","text":"..."}}
You may also ask the server to run tools by including a "tools" array. The server Rules:
executes them and you get the results before the final reply. Tools: - Interpret needs, not just keywords: e.g. "get my kid to school" => a kids' bike; "garden" => lawn/yard services.
- {"name":"search","args":{"q":"...","kind":"product|service|any","limit":6}} - Respect budgets (prices in CATALOG are integer cents: 5500 = $55.00).
- {"name":"get_listing","args":{"id":"lst_..."}} - If nothing fits, say so and show the closest options with show_cards.
- {"name":"create_listing","args":{"title":"...","kind":"product|service","price":cents,"description":"...","category":"...","area":"...","payment_options":["online","cash"],"seller":"demo-seller","seller_type":"human","emoji":"📦"}} - Online payment adds a FEE_PERCENT facilitation fee paid by the buyer; cash in person has no fee. Mention this when relevant.
- {"name":"start_checkout","args":{"listing_id":"...","buyer":"demo-buyer","quantity":1}} - Usually return exactly one main panel action. Keep "reply" short and do not repeat the whole catalog in text.
- {"name":"start_cash_deal","args":{"listing_id":"...","buyer":"demo-buyer","place":"...","time":"..."}} - Optional: if you truly need a server action first, add "tools":[{"name":"search","args":{"q":"..."}}] (also
- {"name":"complete_cash_deal","args":{"deal_id":"cash_..."}} create_listing, start_checkout, start_cash_deal); you will get TOOL_RESULTS and then answer. Prefer not to.
Card details never reach you: the checkout panel posts them straight to the test payment
endpoint and then sends you a UI_EVENT {"type":"payment_result",...}. React with show_receipt.
UI_EVENT messages are clicks/forms from the page (JSON). Handle them:
view_listing -> show_cards with that id; buy_online -> start_checkout then show_checkout;
buy_cash -> start_cash_deal then show_cash_meetup; compare -> show_compare;
sell -> show_listing_form; submit_listing -> create_listing then show_cards with the new id;
payment_result -> show_receipt; cash_completed -> complete_cash_deal then show_receipt.
Prefer one clear panel at a time. For "show me bikes" run search then show_cards.
For "I want to buy X" show_checkout. For "meet in person" show_cash_meetup.
For "I want to sell" show_listing_form. Keep reply under 80 words.
""".replace("FEE_PERCENT", f"{FEE_RATE*100:.1f}%") """.replace("FEE_PERCENT", f"{FEE_RATE*100:.1f}%")
@ -407,113 +391,202 @@ def fallback_agent(store, message: str, history, actor: str = "demo-buyer"):
# ---------- LLM path ---------- # ---------- LLM path ----------
def _llm_config(): LLM_TIMEOUT = float(os.environ.get("AGENT_MARKET_LLM_TIMEOUT", "12")) # hard cap per model attempt
"""Hosted LLM config from env only (OpenAI-compatible chat completions). Never logs the key.""" LLM_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_BUDGET", "28")) # hard cap across the whole chain
key_env = None _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}
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"): for env in ("AGENT_MARKET_LLM_API_KEY", "XAI_API_KEY", "OPENAI_API_KEY"):
if os.environ.get(env): if os.environ.get(env):
key_env = env return os.environ[env]
break
if not key_env:
return None return None
defaults = {
"XAI_API_KEY": ("https://api.x.ai/v1", "grok-3-mini"),
"OPENAI_API_KEY": ("https://api.openai.com/v1", "gpt-4o-mini"), def _llm_config():
"AGENT_MARKET_LLM_API_KEY": ("", ""), """Hosted LLM config (OpenAI-compatible chat completions) with an ordered model fallback chain."""
}[key_env] if not _read_key():
base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or defaults[0] return None
model = os.environ.get("AGENT_MARKET_LLM_MODEL") or defaults[1] base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or ""
if not base or not model: 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 not base or not models:
return None return None
return {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions", return {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions",
"model": model, "env": key_env} "models": models, "model": models[0]}
def _call_llm(cfg, messages): def _call_llm(cfg, model, messages, json_mode=True):
key = os.environ[cfg["env"]] payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 900}
body = json.dumps({ if json_mode:
"model": cfg["model"], payload["response_format"] = {"type": "json_object"}
"messages": messages, req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers={
"temperature": 0.2, "Authorization": "Bearer " + _read_key(),
"response_format": {"type": "json_object"},
}).encode()
req = urllib.request.Request(cfg["url"], data=body, method="POST", headers={
"Authorization": f"Bearer {key}",
"Content-Type": "application/json", "Content-Type": "application/json",
"X-Title": "Agent Market demo",
}) })
with urllib.request.urlopen(req, timeout=45) as r: try:
return json.loads(r.read().decode()) with urllib.request.urlopen(req, timeout=LLM_TIMEOUT) as r:
data = json.loads(r.read().decode())
except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:300]
if e.code == 429:
raise LLMError("rate_limited", body)
if e.code == 400 and json_mode and "response_format" in body:
return _call_llm(cfg, model, messages, json_mode=False)
raise LLMError(f"http_{e.code}", body)
except (urllib.error.URLError, TimeoutError, OSError) as e:
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): def _parse_agent_json(text: str):
text = (text or "").strip() text = re.sub(r"<think>.*?</think>", "", text or "", flags=re.S).strip()
if text.startswith("```"): text = re.sub(r"^```(?:json)?\s*|\s*```$", "", text).strip()
text = re.sub(r"^```(?:json)?\s*", "", text) try:
text = re.sub(r"\s*```$", "", text) obj = json.loads(text)
return 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 llm_agent(store, message: str, history, cfg): def _catalog(store):
# Round 1: may request tools return [{"id": L["id"], "title": L["title"], "kind": L["kind"], "price_cents": L["price"],
catalog = [{"id": L["id"], "title": L["title"], "kind": L["kind"], "price": L["price"], "category": L.get("category"), "about": (L.get("description") or "")[:110],
"tags": L.get("tags"), "payment_options": L.get("payment_options")} "seller_type": L.get("seller_type"), "pay": L.get("payment_options")}
for L in store.all("listings") if L.get("status") == "active"] for L in store.all("listings") if L.get("status") == "active"][:60]
sys = UI_SCHEMA + "\n\nCURRENT_LISTINGS_SUMMARY:\n" + json.dumps(catalog)[:4000]
msgs = [{"role": "system", "content": sys}]
for h in (history or [])[-8:]: def _llm_once(store, cfg, model, message, history):
sys_prompt = UI_SCHEMA + "\nCATALOG (all active listings):\n" + json.dumps(_catalog(store))[:9000]
msgs = [{"role": "system", "content": sys_prompt}]
for h in (history or [])[-6:]:
if h.get("role") in ("user", "assistant") and h.get("content"): if h.get("role") in ("user", "assistant") and h.get("content"):
msgs.append({"role": h["role"], "content": str(h["content"])[:1000]}) msgs.append({"role": h["role"], "content": str(h["content"])[:600]})
msgs.append({"role": "user", "content": message}) msgs.append({"role": "user", "content": message})
content = _call_llm(cfg, model, msgs)
raw1 = _call_llm(cfg, msgs) plan = _parse_agent_json(content)
content1 = raw1["choices"][0]["message"]["content"] tool_results = []
plan = _parse_agent_json(content1) tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3]
tools = plan.get("tools") or []
tool_results = run_tools(store, tools) if tools else []
if tools: if tools:
msgs.append({"role": "assistant", "content": content1}) tool_results = run_tools(store, tools)
msgs.append({"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] + msgs += [{"role": "assistant", "content": content},
"\nNow return the final {reply, actions} JSON. Do not include tools."}) {"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] +
raw2 = _call_llm(cfg, msgs) "\nNow return the final {\"reply\",\"actions\"} JSON only."}]
content2 = raw2["choices"][0]["message"]["content"] plan = _parse_agent_json(_call_llm(cfg, model, msgs))
plan = _parse_agent_json(content2) 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", "")
return {"reply": (reply or "Here you go.")[:600], "actions": actions, "tools": tool_results}
actions = plan.get("actions") or []
# sanitize action types def llm_agent(store, message, history, cfg):
allowed = ALLOWED_ACTIONS """Try each model in the chain; skip models cooling down after a 429. Raises if all fail."""
actions = [a for a in actions if isinstance(a, dict) and a.get("type") in allowed] errors = []
return { now_t = time.time()
"reply": str(plan.get("reply") or "Done.")[:500], chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t] or cfg["models"][-1:]
"actions": actions, start = time.time()
"agent": cfg["provider"], for model in chain:
"model": cfg["model"], t0 = time.time()
"tools": tool_results, 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)
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)
return out
except LLMError as e:
if e.kind == "rate_limited":
_cooldown[model] = time.time() + COOLDOWN_429
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)})
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 handle_chat(store, message: str, history=None, event=None, actor="demo-buyer"): def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer"):
actor = (actor or "demo-buyer")[:60] actor = (actor or "demo-buyer")[:60]
t0 = time.time()
if event and (event.get("type") == "landing" or not LLM_EVENTS):
if event.get("type") == "landing":
out = fallback_agent(store, "show me everything", [], actor)
else:
out = fallback_event(store, event, actor)
out.update(agent="ui-event", latency_ms=int((time.time() - t0) * 1000))
return out
cfg = _llm_config() cfg = _llm_config()
if cfg: if cfg:
text = message text = message if not event else "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
if event:
text = "UI_EVENT " + json.dumps(event)[:2000] + f"\n(actor/buyer id: {actor})"
try: try:
return llm_agent(store, text, history or [], cfg) return llm_agent(store, text, history or [], cfg)
except Exception as e: except Exception as e:
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor) out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor)
out["agent"] = "fallback" out.update(agent="fallback", llm_error=getattr(e, "kind", type(e).__name__),
out["llm_error"] = type(e).__name__ fallbacks=json.loads(str(e).split(": ", 1)[1]) if getattr(e, "kind", "") == "all_models_failed" else [],
latency_ms=int((time.time() - t0) * 1000))
out["reply"] = "(The AI model is busy right now, so the simple built-in agent answered.) " + out["reply"]
return out return out
out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor) out = fallback_event(store, event, actor) if event else fallback_agent(store, message, history or [], actor)
out["agent"] = "fallback" out.update(agent="fallback", latency_ms=int((time.time() - t0) * 1000))
return out return out
def agent_status(): def agent_status():
cfg = _llm_config() cfg = _llm_config()
if cfg: if cfg:
return {"mode": "llm", "provider": cfg["provider"], "model": cfg["model"]} now_t = time.time()
return {"mode": "llm", "provider": cfg["provider"], "model": cfg["models"][0], "chain": cfg["models"],
"cooling_down": [m for m in cfg["models"] if _cooldown.get(m, 0) > now_t],
"last_model": LAST["model"], "last_error": LAST["error"], "ui_events": "llm" if LLM_EVENTS else "deterministic"}
return {"mode": "fallback", "provider": "deterministic", "model": None, return {"mode": "fallback", "provider": "deterministic", "model": None,
"note": "No hosted LLM key configured (set AGENT_MARKET_LLM_API_KEY + AGENT_MARKET_LLM_BASE_URL + AGENT_MARKET_LLM_MODEL, or XAI_API_KEY / OPENAI_API_KEY). Using the labelled rule-based fallback agent."} "note": "No hosted LLM configured (set AGENT_MARKET_LLM_API_KEY_FILE or AGENT_MARKET_LLM_API_KEY, plus AGENT_MARKET_LLM_BASE_URL and AGENT_MARKET_LLM_MODELS). Using the labelled rule-based fallback agent."}

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Agent Market demo server. Tiny on purpose: static files, an LLM proxy (key stays server-side), """Agent Market demo server. Tiny on purpose: static files, an LLM proxy (key stays server-side),
test-mode payment endpoints, and a small JSON action API shared by the chat agent and outside agents.""" test-mode payment endpoints, and a small JSON action API shared by the chat agent and outside agents."""
import json, os, re, secrets, sys, urllib.parse import json, os, re, secrets, sys, threading, time, urllib.parse
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
@ -16,6 +16,28 @@ ENV_LABEL = os.environ.get("AGENT_MARKET_ENV", "demo")
STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") STATIC = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
WEBHOOK_SECRET = os.environ.get("AGENT_MARKET_WEBHOOK_SECRET") or secrets.token_hex(16) # per-process if unset WEBHOOK_SECRET = os.environ.get("AGENT_MARKET_WEBHOOK_SECRET") or secrets.token_hex(16) # per-process if unset
MAX_BODY = 64 * 1024 MAX_BODY = 64 * 1024
CHAT_RPM = int(os.environ.get("AGENT_MARKET_CHAT_RPM", "12")) # per-IP chat requests per minute
CHAT_BURST = int(os.environ.get("AGENT_MARKET_CHAT_BURST", "6"))
_buckets = {} # ip -> [tokens, last_ts]
_bucket_lock = threading.Lock()
def rate_ok(ip):
"""Token bucket per client IP. Returns (allowed, retry_after_seconds)."""
if CHAT_RPM <= 0:
return True, 0
rate = CHAT_RPM / 60.0
with _bucket_lock:
now_t = time.time()
tokens, last = _buckets.get(ip, (float(CHAT_BURST), now_t))
tokens = min(float(CHAT_BURST), tokens + (now_t - last) * rate)
if tokens >= 1:
_buckets[ip] = (tokens - 1, now_t)
return True, 0
_buckets[ip] = (tokens, now_t)
if len(_buckets) > 5000:
_buckets.clear()
return False, int((1 - tokens) / rate) + 1
store = Store(DATA_DIR) store = Store(DATA_DIR)
@ -196,13 +218,24 @@ class H(BaseHTTPRequestHandler):
a = self.headers.get("X-Agent-Id") or (body or {}).get("actor") or "guest" a = self.headers.get("X-Agent-Id") or (body or {}).get("actor") or "guest"
return re.sub(r"[^A-Za-z0-9:_\-. ()]", "", str(a))[:60] or "guest" return re.sub(r"[^A-Za-z0-9:_\-. ()]", "", str(a))[:60] or "guest"
def _json(self, code, obj): def _client_ip(self):
# Only trust proxy headers when the peer is the local tunnel (cloudflared) on loopback.
peer = self.client_address[0]
if peer in ("127.0.0.1", "::1"):
fwd = self.headers.get("CF-Connecting-IP") or (self.headers.get("X-Forwarded-For") or "").split(",")[0].strip()
if fwd:
return fwd
return peer
def _json(self, code, obj, extra_headers=None):
b = json.dumps(obj).encode() b = json.dumps(obj).encode()
self.send_response(code) self.send_response(code)
self.send_header("Content-Type", "application/json") self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(b))) self.send_header("Content-Length", str(len(b)))
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_header("Access-Control-Allow-Origin", "*") self.send_header("Access-Control-Allow-Origin", "*")
for k, v in (extra_headers or {}).items():
self.send_header(k, v)
self.end_headers() self.end_headers()
self.wfile.write(b) self.wfile.write(b)
@ -279,6 +312,11 @@ class H(BaseHTTPRequestHandler):
return self._json(200, self._webhook(body)) return self._json(200, self._webhook(body))
actor = self._actor(body) actor = self._actor(body)
if path == "/api/chat": if path == "/api/chat":
ok, retry = rate_ok(self._client_ip())
if not ok:
return self._json(429, {"error": "rate_limited", "retry_after": retry, "agent": "rate-limit",
"reply": f"You're sending messages quickly. Please wait about {retry}s and try again.",
"actions": []}, {"Retry-After": str(retry)})
msg = str(body.get("message") or "")[:2000] msg = str(body.get("message") or "")[:2000]
ev = body.get("event") if isinstance(body.get("event"), dict) else None ev = body.get("event") if isinstance(body.get("event"), dict) else None
hist = body.get("history") if isinstance(body.get("history"), list) else [] hist = body.get("history") if isinstance(body.get("history"), list) else []

View File

@ -33,6 +33,12 @@
return r.json(); return r.json();
} }
function setModel(model, ms) {
const short = (model || "").replace(/:free$/, "");
$("#agentBadge").textContent = "AI: " + short;
$("#agentBadge").title = (model || "") + " via OpenRouter (free tier)";
$("#chatMode").textContent = `AI agent · ${short}${ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""}`;
}
async function talk({ message, event, echo }) { async function talk({ message, event, echo }) {
openChat(); openChat();
if (echo) say("user", echo); if (echo) say("user", echo);
@ -44,7 +50,9 @@
if (event) history.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) }); if (event) history.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) });
history.push({ role: "assistant", content: out.reply || "" }); history.push({ role: "assistant", content: out.reply || "" });
say("bot", out.reply || "(no reply)"); say("bot", out.reply || "(no reply)");
$("#chatMode").textContent = out.agent === "fallback" ? "rule-based fallback agent (no LLM key)" : `LLM agent · ${out.model || out.agent}`; if (out.agent === "llm") setModel(out.model, out.latency_ms);
else if (out.agent === "fallback") $("#chatMode").textContent = "built-in fallback agent answered (AI model busy)";
else if (out.agent === "rate-limit") toast(out.reply, "warn");
render(out.actions || []); render(out.actions || []);
} catch (e) { thinking.remove(); say("sys", "Agent error: " + e.message); } } catch (e) { thinking.remove(); say("sys", "Agent error: " + e.message); }
} }
@ -74,6 +82,7 @@
}, },
show_listing_form(p) { show_listing_form(p) {
const d = p.defaults || {}; const d = p.defaults || {};
const payOpts = Array.isArray(d.payment_options) && d.payment_options.length ? d.payment_options : ["online", "cash"];
const f = el("form", { class: "form", "data-component": "listing-form" }, const f = el("form", { class: "form", "data-component": "listing-form" },
el("label", { class: "full" }, "Title", el("input", { name: "title", required: "", value: d.title || "", placeholder: "e.g. Vintage desk chair" })), el("label", { class: "full" }, "Title", el("input", { name: "title", required: "", value: d.title || "", placeholder: "e.g. Vintage desk chair" })),
el("label", {}, "Type", (() => { const s = el("select", { name: "kind" }, el("option", { value: "product" }, "Product"), el("option", { value: "service" }, "Service")); s.value = d.kind || "product"; return s; })()), el("label", {}, "Type", (() => { const s = el("select", { name: "kind" }, el("option", { value: "product" }, "Product"), el("option", { value: "service" }, "Service")); s.value = d.kind || "product"; return s; })()),
@ -81,8 +90,8 @@
el("label", {}, "Category", el("input", { name: "category", value: d.category || "" })), el("label", {}, "Category", el("input", { name: "category", value: d.category || "" })),
el("label", {}, "Area (approximate)", el("input", { name: "area", value: d.area || "Demo Town" })), el("label", {}, "Area (approximate)", el("input", { name: "area", value: d.area || "Demo Town" })),
el("label", { class: "full" }, "Description", el("textarea", { name: "description", rows: "3", required: "" }, d.description || "")), el("label", { class: "full" }, "Description", el("textarea", { name: "description", rows: "3", required: "" }, d.description || "")),
el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "online", checked: "" }), ` Accept online payment (${feeBps / 100}% fee, paid by buyer)`)), el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "online", checked: payOpts.includes("online") ? "" : null }), ` Accept online payment (${feeBps / 100}% fee, paid by buyer)`)),
el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "cash", checked: "" }), " Accept cash in person (free)")), el("label", {}, el("span", {}, el("input", { type: "checkbox", name: "cash", checked: payOpts.includes("cash") ? "" : null }), " Accept cash in person (free)")),
el("div", { class: "full" }, el("button", { class: "btn", type: "submit" }, "Publish test listing"))); el("div", { class: "full" }, el("button", { class: "btn", type: "submit" }, "Publish test listing")));
f.addEventListener("submit", (e) => { f.addEventListener("submit", (e) => {
e.preventDefault(); e.preventDefault();
@ -201,12 +210,12 @@
api("/api/status").then((s) => { api("/api/status").then((s) => {
feeBps = s.fee_bps; $("#feePct").textContent = (feeBps / 100).toFixed(feeBps % 100 ? 1 : 0) + "%"; feeBps = s.fee_bps; $("#feePct").textContent = (feeBps / 100).toFixed(feeBps % 100 ? 1 : 0) + "%";
$("#agentBadge").textContent = s.agent.mode === "llm" ? `LLM: ${s.agent.model}` : "agent: rule-based fallback"; if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model);
$("#agentBadge").title = s.agent.note || ""; else { $("#agentBadge").textContent = "agent: rule-based fallback"; $("#agentBadge").title = s.agent.note || ""; }
}); });
// Landing: show featured listings via the agent. // Landing: show featured listings via the agent.
(async () => { (async () => {
const out = await api("/api/chat", { message: "show me everything", actor }); const out = await api("/api/chat", { event: { type: "landing" }, actor });
render(out.actions || []); render(out.actions || []);
say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only."); say("bot", "Hi! I run this page. Ask me to find, compare, sell, or buy. Payments are test mode only.");
})(); })();

View File

@ -1,20 +1,34 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Exercises the hosted-LLM proxy path with a local fake OpenAI-compatible server (no real key needed). # LLM proxy path against a local fake OpenAI-compatible server (no real key needed):
# tool call -> hydrated cards, key stays server-side, 429 fallback chain, rule-based last resort, per-IP rate limit.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")/.." cd "$(dirname "$0")/.."
D=$(mktemp -d) D=$(mktemp -d); KEYF=$D/key; printf 'test-dummy-key' > $KEYF; chmod 600 $KEYF
python3 tests/mock_llm.py 18798 & M=$! python3 tests/mock_llm.py 18798 & M=$!
AGENT_MARKET_PORT=18799 AGENT_MARKET_DATA=$D AGENT_MARKET_LLM_API_KEY=test-dummy-key \ start() { # port models rpm
AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18798/v1 AGENT_MARKET_LLM_MODEL=mock-model python3 server.py >/dev/null 2>&1 & S=$! AGENT_MARKET_PORT=$1 AGENT_MARKET_DATA=$D/d$1 AGENT_MARKET_LLM_API_KEY_FILE=$KEYF AGENT_MARKET_CHAT_RPM=$3 AGENT_MARKET_CHAT_BURST=3 \
trap 'kill $M $S 2>/dev/null; rm -rf $D' EXIT AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18798/v1 AGENT_MARKET_LLM_MODELS=$2 python3 server.py >/dev/null 2>&1 & echo $!; }
sleep 1.2 S1=$(start 18799 busy-model,mock-model 0); S2=$(start 18800 busy-model,busy-model-2 0); S3=$(start 18801 mock-model 2)
OUT=$(curl -s localhost:18799/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}') trap 'kill $M $S1 $S2 $S3 2>/dev/null; rm -rf $D' EXIT
sleep 1.5
chat() { curl -s localhost:$1/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}'; }
OUT=$(chat 18799)
echo "$OUT" | python3 -c ' echo "$OUT" | python3 -c '
import json,sys; d=json.load(sys.stdin) import json,sys; d=json.load(sys.stdin)
assert d["agent"]!="fallback", d assert d["agent"]=="llm" and d["model"]=="mock-model", d
assert d["fallbacks"][0]=={"model":"busy-model","error":"rate_limited","ms":d["fallbacks"][0]["ms"]}, d["fallbacks"]
types=[a["type"] for a in d["actions"]] types=[a["type"] for a in d["actions"]]
assert types==["show_cards"], types # unknown component from the LLM is dropped assert types==["show_cards"], types
assert all("bike" in l["title"].lower() for l in d["actions"][0]["payload"]["listings"]) assert all("bike" in l["title"].lower() for l in d["actions"][0]["payload"]["listings"])
print("PASS LLM proxy path: tool call -> hydrated cards; disallowed component dropped")' print("PASS 429 on first model -> second model answered; cards hydrated; disallowed component dropped")'
if echo "$OUT" | grep -q test-dummy-key; then echo "FAIL key leaked to browser"; exit 1; else echo "PASS key never sent to browser"; fi if echo "$OUT" | grep -q test-dummy-key; then echo "FAIL key leaked to browser"; exit 1; else echo "PASS key never sent to browser"; fi
test "$(curl -s localhost:18798/ | python3 -c 'import json,sys;print(json.load(sys.stdin)["auth_ok"])')" -ge 2 && echo "PASS server sent key to LLM upstream only" test "$(curl -s localhost:18798/ | python3 -c 'import json,sys;print(json.load(sys.stdin)["auth_ok"])')" -ge 2 && echo "PASS key read from file and sent upstream only"
chat 18800 | python3 -c '
import json,sys; d=json.load(sys.stdin)
assert d["agent"]=="fallback" and d["llm_error"]=="all_models_failed" and d["actions"], d
print("PASS all models rate-limited -> rule-based agent still renders", [a["type"] for a in d["actions"]])'
curl -s localhost:18800/api/status | python3 -c '
import json,sys; a=json.load(sys.stdin)["agent"]; assert set(a["cooling_down"])=={"busy-model","busy-model-2"}, a; print("PASS 429ed models put on cooldown")'
CODES=$(for i in 1 2 3 4 5; do curl -s -o /dev/null -w '%{http_code} ' localhost:18801/api/chat -H 'Content-Type: application/json' -H 'CF-Connecting-IP: 203.0.113.9' -d '{"event":{"type":"landing"}}'; done)
OTHER=$(curl -s -o /dev/null -w '%{http_code}' localhost:18801/api/chat -H 'Content-Type: application/json' -H 'CF-Connecting-IP: 203.0.113.10' -d '{"event":{"type":"landing"}}')
[[ "$CODES" == "200 200 200 429 429 " && "$OTHER" == "200" ]] && echo "PASS per-IP rate limit (burst 3: $CODES; other IP $OTHER)" || { echo "FAIL rate limit: $CODES / $OTHER"; exit 1; }

View File

@ -12,6 +12,10 @@ class H(BaseHTTPRequestHandler):
b = json.dumps(SEEN).encode(); self.send_response(200); self.end_headers(); self.wfile.write(b) b = json.dumps(SEEN).encode(); self.send_response(200); self.end_headers(); self.wfile.write(b)
def do_POST(self): def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"]))) body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
if body.get("model") in ("busy-model", "busy-model-2"):
b = b'{"error":{"message":"rate-limited upstream","code":429}}'
self.send_response(429); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
return
if self.headers.get("Authorization") == "Bearer test-dummy-key": if self.headers.get("Authorization") == "Bearer test-dummy-key":
SEEN["auth_ok"] += 1 SEEN["auth_ok"] += 1
last = body["messages"][-1]["content"] last = body["messages"][-1]["content"]