agent-market/tests/mock_llm.py
leora d785091d64 Typed chat: hosted free models, then the local model, nothing else; busy reply + Retry; llama-server watchdog
- 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.
2026-10-08 21:58:41 +08:00

64 lines
4.5 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fake OpenAI-compatible endpoint to exercise the LLM proxy path without a real key.
Round 1 asks for a search tool; round 2 returns show_cards with ids from the tool results."""
import json, re, sys, time
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8798
SEEN = {"auth_ok": 0, "calls": {}, "auth": {}, "grammar": {}, "local_prompt": {}}
class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
b = json.dumps(SEEN).encode(); self.send_response(200); self.end_headers(); self.wfile.write(b)
def do_POST(self):
body = json.loads(self.rfile.read(int(self.headers["Content-Length"])))
SEEN["calls"][body.get("model")] = SEEN["calls"].get(body.get("model"), 0) + 1
if body.get("model") == "slow-local":
time.sleep(3)
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
mdl = body.get("model")
if self.headers.get("Authorization") == "Bearer test-dummy-key":
SEEN["auth_ok"] += 1
SEEN["auth"][mdl] = SEEN["auth"].get(mdl, 0) + 1
if body.get("grammar"):
SEEN["grammar"][mdl] = SEEN["grammar"].get(mdl, 0) + 1
if "EXAMPLES" in body["messages"][0]["content"] and "VISITOR MESSAGE" in body["messages"][-1]["content"]:
SEEN["local_prompt"][mdl] = SEEN["local_prompt"].get(mdl, 0) + 1
if body.get("model") == "daily-model":
b = json.dumps({"error": {"message": "Rate limit exceeded: free-models-per-day.", "code": 429,
"metadata": {"headers": {"X-RateLimit-Limit": "50", "X-RateLimit-Remaining": "0", "X-RateLimit-Reset": "4102444800000"}}}}).encode()
self.send_response(429); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
return
last = body["messages"][-1]["content"]
if "new internet" in last.lower():
# answers like a model that read the prompt: it only knows "top left" = copy.logo if the prompt says so
sysmsg = body["messages"][0]["content"]
knows = "TOP-LEFT" in sysmsg and '"logo": "Agent Market"' in sysmsg
out = ({"reply": "Renamed the top-left logo to New Internet.", "actions": [{"type": "set_theme", "payload": {"copy": {"logo": "New Internet"}}}]}
if knows else {"reply": "I can't change that text.", "actions": []})
b = json.dumps({"choices": [{"message": {"role": "assistant", "content": json.dumps(out)}}]}).encode()
self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
return
if "background" in last:
out = {"reply": "Done! Sunset gradient applied (lst_deadbeef).", "actions": [{"type": "set_theme", "payload": {
"background": {"kind": "gradient", "colors": ["#ff7e5f", "#feb47b", "javascript:alert(1)"], "angle": 180},
"palette": {"accent": "orange"}, "customCss": "@import url(https://evil.example/x.css); .card{border-width:3px;position:fixed}",
"copy": {"headline": "<img src=x onerror=alert(1)>Sunset"}}}]}
b = json.dumps({"choices": [{"message": {"role": "assistant", "content": json.dumps(out)}}]}).encode()
self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
return
if last.startswith("TOOL_RESULTS"):
ids = re.findall(r'"id": "(lst_[a-f0-9]+)"', last)[:3]
out = {"reply": "Here are bikes I found (mock LLM). Payments are TEST MODE ONLY.",
"actions": [{"type": "show_cards", "payload": {"heading": "Bikes", "listing_ids": ids}},
{"type": "not_allowed_component", "payload": {}}]}
else:
out = {"reply": "searching", "tools": [{"name": "search", "args": {"q": "bike"}}]}
b = json.dumps({"choices": [{"message": {"role": "assistant", "content": json.dumps(out)}}]}).encode()
self.send_response(200); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)
ThreadingHTTPServer(("127.0.0.1", PORT), H).serve_forever()