From 55cccc4d9a597d91393b53679327b5ec8794937f Mon Sep 17 00:00:00 2001 From: leora Date: Thu, 8 Oct 2026 21:11:57 +0800 Subject: [PATCH] Local backup model (Ollama, gemma3:4b): hosted chain -> local model -> keyword rules; own timeout, straight to local while the daily quota is used up, quota stop persisted; honest 'Local model' badge; mock + browser tests; watchdog script --- README.md | 41 ++++++++- deploy.sh | 2 + market/agent.py | 168 +++++++++++++++++++++++++++++------- market/theme.py | 7 +- ops/ollama-watchdog.sh | 34 ++++++++ server.py | 4 + tests/browser_e2e.js | 2 +- tests/fake_model_browser.js | 12 ++- tests/fake_model_browser.sh | 5 +- tests/llm_proxy_check.sh | 36 +++++++- tests/mock_llm.py | 7 +- tests/theme_check.js | 4 +- web/src/App.tsx | 22 +++-- web/src/types.ts | 6 +- 14 files changed, 299 insertions(+), 51 deletions(-) create mode 100755 ops/ollama-watchdog.sh diff --git a/README.md b/README.md index 5e9a78c..0e677b4 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ never changes the page for anyone else. **Reset look** in the header, or saying | anything else | refused (`not_a_test_card`) | ## LLM configuration -The model has to be hosted. The server talks to any OpenAI-compatible `/chat/completions` endpoint, and the +The server talks to any OpenAI-compatible `/chat/completions` endpoint, and the key stays on the server: it never goes to the browser, never into the repo, and never into a URL. | Variable | Meaning | @@ -104,7 +104,7 @@ key stays on the server: it never goes to the browser, never into the repo, and `deploy.sh` loads `~/.config/agent-market/llm.env` and `~/.config/agent-market/.env` if they exist. Both sit outside the repo. The box runs OpenRouter free models: -`nvidia/nemotron-3-super-120b-a12b:free` → `apodex/apodex-1.1-mini:free` → `google/gemma-4-31b-it:free` → rule-based agent +`nvidia/nemotron-3-super-120b-a12b:free` → `apodex/apodex-1.1-mini:free` → `google/gemma-4-31b-it:free` → **local `gemma3:4b`** → rule-based agent The order came from a benchmark of natural-language prompts, scored on valid UI actions and latency. In that run: - **nemotron-3-super**: 8/8 valid, about 2–3 s. @@ -116,6 +116,43 @@ The order came from a benchmark of natural-language prompts, scored on valid UI If a model returns 429, it times out, or it returns invalid JSON, the server moves on to the next model in the chain. When every model fails, the labelled rule-based agent answers, so the page never breaks. The header badge shows the model that answered. +### Local backup model +A small model on the box runs through [Ollama](https://ollama.com). The server tries it after the hosted chain and before +the keyword rules. It uses the same prompt and OpenAI-compatible endpoint (`/v1/chat/completions` with JSON mode), with no key. + +| Variable | Meaning | +|---|---| +| `AGENT_MARKET_LOCAL_LLM_URL` | e.g. `http://127.0.0.1:11434/v1` (loopback only) | +| `AGENT_MARKET_LOCAL_LLM_MODEL` | e.g. `gemma3:4b` | +| `AGENT_MARKET_LOCAL_LLM_TIMEOUT` / `_MIN` | its own cap (45 s; CPU inference is slower) and the minimum it always gets (25 s) | +| `AGENT_MARKET_LLM_TOTAL_BUDGET` | hosted chain + local model per turn (60 s); the local model gets `min(45, max(25, 60 - time already spent))` | +| `AGENT_MARKET_LOCAL_LLM_CONCURRENCY` | 2 = one running + one queued; more simultaneous turns go to the keyword rules instead of waiting | + +`deploy.sh` loads these from `~/.config/agent-market/local-llm.env`. The behaviour: +- **Quota stop.** When OpenRouter reports the daily free quota is used up, every hosted model is parked until the reset + time and turns go **straight to the local model**, with no hosted calls in between. The stop is saved in the data + directory (`llm_quota.json`, keyed by the base URL, capped at 36 h), so a restart doesn't spend a call to rediscover it. +- **Badge.** It honestly shows `Local model (gemma3:4b)` when the local model answered (chat header: `Local model · gemma3:4b · 8.2s`), + and also on page load while the hosted quota is used up. +- **Running it.** `ops/ollama-watchdog.sh` keeps `ollama serve` on `127.0.0.1:11434` with `OLLAMA_KEEP_ALIVE=-1`, 8k context, + one loaded model and a q8 KV cache. It pre-loads the configured model, and restarts Ollama if it exits or stops answering + (3 failed health checks, 20 s apart). Install Ollama under `~/ollama` (no root), pull the model (`ollama pull gemma3:4b`), then + `nohup setsid ~/ollama/watchdog.sh >> ~/ollama/watchdog.log 2>&1 &`. +- **Model choice.** Benchmarked on this box (8 vCPU Xeon with AVX-512/AMX, no GPU, 16 GB RAM) with the real prompt and + catalog. The first call after the prompt prefix changes processes about 2.3k prompt tokens (about 30 s); later calls reuse + Ollama's prompt cache. + + | model | correct | warm latency | notes | + |---|---|---|---| + | **gemma3:4b** (chosen) | 15/15 | 4–10 s (median 8 s), cold 28–33 s | about 3.4 GB RAM loaded | + | qwen2.5:3b | 7/15 | 3–8 s | dropped actions, invented ids, flat payloads | + | phi4-mini | 4/5 | 10–25 s | missed the logo edit | + | qwen3:4b (no think) | 4/5 | 12–22 s | slow, missed the logo edit | + | llama3.2:3b | 1/5 | 4–8 s | mostly empty plans | + | qwen2.5:1.5b | 0/5 | 3–6 s | no usable plans | + + 7B models were left out: they need about 5 GB more RAM, and on this CPU they would be about 2–3x slower. + **Free text goes to the LLM. Structured UI events stay deterministic.** Button clicks, form submits and payment results each have exactly one outcome, so the server handles them deterministically. That keeps them instant and saves free-tier quota. The landing view doesn't call the model either. Set `AGENT_MARKET_LLM_EVENTS=1` to send events to the LLM as well. diff --git a/deploy.sh b/deploy.sh index fe2eff7..6f41c77 100755 --- a/deploy.sh +++ b/deploy.sh @@ -20,6 +20,8 @@ echo "deploying $ENVN @ $(git rev-parse --short HEAD) on :$PORT" CONF=$HOME/.config/agent-market set -a [ -f "$CONF/llm.env" ] && . "$CONF/llm.env" +# optional local backup model (Ollama on this box, see README "Local backup model") +[ -f "$CONF/local-llm.env" ] && . "$CONF/local-llm.env" [ -f "$CONF/$ENVN.env" ] && . "$CONF/$ENVN.env" # Optional extra env file for offline test runs, e.g. point the LLM at tests/mock_llm.py so no real model calls are made: # AM_EXTRA_ENV=/path/offline.env ./deploy.sh staging my-branch diff --git a/market/agent.py b/market/agent.py index db22754..27aeb8b 100644 --- a/market/agent.py +++ b/market/agent.py @@ -4,7 +4,8 @@ Returns {reply, actions} where actions is a list of UI action objects from a fix component set the frontend knows how to render. """ from __future__ import annotations -import concurrent.futures, json, os, re, time, urllib.request, urllib.error +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% @@ -437,8 +438,39 @@ LLM_BUDGET = float(os.environ.get("AGENT_MARKET_LLM_BUDGET", "28")) # h _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} +LAST = {"model": None, "at": None, "error": None, "local": False} QUOTA = {"until": 0} +# Local backup model (e.g. Ollama on this box, OpenAI-compatible): tried after the hosted chain, before the keyword rules. +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): @@ -464,31 +496,48 @@ def _read_key(): 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.""" - if not _read_key(): + """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 - 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 not base or not models: - return None - return {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions", - "models": models, "model": models[0]} + cfg = remote or {"provider": "local", "url": None, "models": [], "model": local["model"]} + cfg["local"] = local + return cfg -def _call_llm(cfg, model, messages, json_mode=True): +def _call_llm(cfg, model, messages, json_mode=True, timeout=None): payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100} if json_mode: - payload["response_format"] = {"type": "json_object"} - req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers={ - "Authorization": "Bearer " + _read_key(), - "Content-Type": "application/json", - "X-Title": "Agent Market demo", - }) + payload["response_format"] = {"type": "json_object"} # Ollama maps this to JSON mode + headers = {"Content-Type": "application/json", "X-Title": "Agent Market demo"} + if cfg.get("local"): + payload["max_tokens"] = 700 + payload["keep_alive"] = -1 # Ollama: keep the model loaded (the server default is set too) + else: + 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=LLM_TIMEOUT) as r: + 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] @@ -498,9 +547,11 @@ def _call_llm(cfg, model, messages, json_mode=True): 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: - return _call_llm(cfg, model, messages, json_mode=False) + 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"] @@ -548,7 +599,7 @@ def _page_text(look): "chips": [x.get("label") for x in c["chips"]], "bullets": c["bullets"]}, ensure_ascii=False) -def _llm_once(store, cfg, model, message, history, look=""): +def _llm_once(store, cfg, model, message, history, look="", timeout=None): sys_prompt = (UI_SCHEMA + "\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)) @@ -557,7 +608,8 @@ def _llm_once(store, cfg, model, message, history, look=""): 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": message}) - content = _call_llm(cfg, model, msgs) + 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] @@ -566,7 +618,10 @@ def _llm_once(store, cfg, model, message, history, look=""): msgs += [{"role": "assistant", "content": content}, {"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] + "\nNow return the final {\"reply\",\"actions\"} JSON only."}] - plan = _parse_agent_json(_call_llm(cfg, model, msgs)) + 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: @@ -576,13 +631,19 @@ def _llm_once(store, cfg, model, message, history, look=""): def llm_agent(store, message, history, cfg, look=""): - """Try each model in the chain; skip models cooling down after a 429. Raises if all fail.""" + """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 backup model with its own timeout. Raises if everything fails (keyword rules answer then).""" errors = [] now_t = time.time() + start = now_t + local = cfg.get("local") if QUOTA["until"] > now_t: - raise LLMError("all_models_failed", json.dumps([{"model": "*", "error": "daily_quota", "ms": 0}])) - chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t] or cfg["models"][-1:] - start = time.time() + 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) @@ -597,7 +658,7 @@ def llm_agent(store, message, history, cfg, look=""): 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) + LAST.update(model=model, at=time.time(), error=None, local=False) return out except LLMError as e: if e.kind == "rate_limited": @@ -609,11 +670,37 @@ def llm_agent(store, message, history, cfg, look=""): 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: + fut = _pool.submit(_llm_once, store, local, local["model"], message, history, 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.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)) @@ -655,13 +742,32 @@ def handle_chat(store, message: str, history=None, event=None, actor="demo-buyer return out +_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() - return {"mode": "llm", "provider": cfg["provider"], "model": cfg["models"][0], "chain": cfg["models"], + 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_error": LAST["error"], "ui_events": "llm" if LLM_EVENTS else "deterministic", + "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": "fallback", "provider": "deterministic", "model": None, "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."} diff --git a/market/theme.py b/market/theme.py index 0c633f8..8da3846 100644 --- a/market/theme.py +++ b/market/theme.py @@ -260,12 +260,15 @@ def build_theme_action(payload): """Turn a model's set_theme payload into a client action: {reset?, base?, patch}.""" if not isinstance(payload, dict): return None - if payload.get("reset"): - return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})} name = str(payload.get("preset") or "").strip().lower() + if payload.get("reset") and not name and not [k for k in payload if k not in ("reset", "patch")]: + return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})} + # {"preset": "sunset", "reset": true} means "start over from that preset" (a preset base already replaces the look) name = PRESET_ALIASES.get(name, name) patch = payload.get("patch") if isinstance(payload.get("patch"), dict) else {k: v for k, v in payload.items() if k not in ("preset", "reset")} out = {"patch": sanitize_patch(patch)} + if payload.get("reset"): + out["reset"] = True if name in PRESETS: out["preset"] = name out["base"] = sanitize_patch(PRESETS[name]) diff --git a/ops/ollama-watchdog.sh b/ops/ollama-watchdog.sh new file mode 100755 index 0000000..441a63c --- /dev/null +++ b/ops/ollama-watchdog.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Local LLM for Agent Market: keeps `ollama serve` running on localhost only and restarts it if it dies or hangs. +# Start: nohup setsid ~/ollama/watchdog.sh >> ~/ollama/watchdog.log 2>&1 & +# Stop: kill $(cat ~/ollama/watchdog.pid) (the trap stops ollama too) +set -u +export OLLAMA_HOST=127.0.0.1:11434 # loopback only, never exposed +export OLLAMA_MODELS=$HOME/ollama/models +export OLLAMA_KEEP_ALIVE=-1 # keep the model in RAM +export OLLAMA_NUM_PARALLEL=1 OLLAMA_MAX_LOADED_MODELS=1 OLLAMA_MAX_QUEUE=8 +export OLLAMA_CONTEXT_LENGTH=${OLLAMA_CONTEXT_LENGTH:-8192} +export OLLAMA_FLASH_ATTENTION=1 OLLAMA_KV_CACHE_TYPE=q8_0 +export OLLAMA_NOPRUNE=1 +# model to pre-load after each (re)start, from the Agent Market config (outside the repo) +WARM=$(sed -n 's/^AGENT_MARKET_LOCAL_LLM_MODEL=//p' "$HOME/.config/agent-market/local-llm.env" 2>/dev/null | tr -d '"' | head -1) +echo $$ > "$HOME/ollama/watchdog.pid" +child=0 +trap '[ $child -gt 0 ] && kill $child 2>/dev/null; exit 0' TERM INT +while true; do + # wait for a previous instance to release the port + for i in $(seq 1 15); do ss -ltn 2>/dev/null | grep -q '127.0.0.1:11434 ' || break; sleep 1; done + "$HOME/ollama/bin/ollama" serve >> "$HOME/ollama/ollama.log" 2>&1 & + child=$! + echo "$(date '+%F %T') started ollama pid $child" + for i in $(seq 1 30); do curl -sf -m 2 http://127.0.0.1:11434/api/version >/dev/null && break; sleep 1; done + [ -n "$WARM" ] && curl -s -m 300 http://127.0.0.1:11434/api/generate -d "{\"model\":\"$WARM\",\"keep_alive\":-1}" >/dev/null & + fails=0 + while kill -0 $child 2>/dev/null; do + sleep 20 + if curl -sf -m 10 http://127.0.0.1:11434/api/version >/dev/null; then fails=0; else fails=$((fails+1)); fi + if [ $fails -ge 3 ]; then echo "$(date '+%F %T') ollama unresponsive, restarting"; kill $child; sleep 3; kill -9 $child 2>/dev/null; fi + done + echo "$(date '+%F %T') ollama exited; restarting in 3s" + sleep 3 +done diff --git a/server.py b/server.py index 00930c4..3b42d25 100755 --- a/server.py +++ b/server.py @@ -147,6 +147,10 @@ def hydrate(actions, actor): continue deal = r["deal"] p.update(deal=deal, listing=store.get("listings", deal["listing_id"]), deal_id=deal["id"]) + elif t == "show_listing_form": + # small models sometimes send the defaults flat ({"title":..., "price":...}); accept both shapes + if not isinstance(p.get("defaults"), dict): + p = {"defaults": {k: v for k, v in p.items() if k in ("title", "kind", "price", "description", "category", "area", "payment_options")}} elif t == "set_theme": act = TH.build_theme_action(p) if not act: diff --git a/tests/browser_e2e.js b/tests/browser_e2e.js index ef5e48f..2b7a22e 100644 --- a/tests/browser_e2e.js +++ b/tests/browser_e2e.js @@ -17,7 +17,7 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); check("landing renders agent-chosen cards", (await page.$$(".card")).length >= 4); check("React app mounted in #root", await page.evaluate(() => !!document.querySelector("#root > .testbar") && !!document.querySelector("#root main#page #stage [data-component=cards]"))); const badge = await page.$eval("#agentBadge", (n) => n.textContent); - check("agent mode badge shown", /fallback|built-in|AI:|LLM/.test(badge), badge); + check("agent mode badge shown", /fallback|built-in|AI:|LLM|Local model/.test(badge), badge); // ask(): type into the chat popup and wait for the agent's reply (LLM replies take a few seconds) const ask = async (text) => { diff --git a/tests/fake_model_browser.js b/tests/fake_model_browser.js index 0f6fb17..b9dcaed 100644 --- a/tests/fake_model_browser.js +++ b/tests/fake_model_browser.js @@ -2,7 +2,7 @@ // texts, quota badge, rate-limit toast, and a model-sent set_theme that the server sanitised. No real model calls. // Usage: NODE_PATH= node tests/fake_model_browser.js const puppeteer = require("puppeteer-core"); -const [OK, BUSY, DAILY, RL] = process.argv.slice(2, 6); +const [OK, BUSY, DAILY, RL, LOCAL] = process.argv.slice(2, 7); const results = []; const check = (name, ok, d = "") => { results.push(!!ok); console.log((ok ? "PASS " : "FAIL ") + name + (d ? ` [${d}]` : "")); }; const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); @@ -59,6 +59,16 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); const toasts = await p.$$eval("#toasts .toast.warn", (ns) => ns.map((n) => n.textContent)); check("rate-limit warning toast", toasts.some((t) => /quickly/.test(t)), toasts.join("|")); + // 6) hosted quota used up -> local backup model answers; badge says so honestly + if (LOCAL) { + p = await open(LOCAL); + await ask(p, "show me bikes"); + const lb = await text(p, "#agentBadge"), lm = await text(p, "#chatMode"); + check("local model badge + chat mode", lb === "Local model (local-model)" && /^Local model · local-model · \d+\.\ds$/.test(lm) && (await p.$$("#stage .card")).length > 0, `${lb} | ${lm}`); + await p.reload({ waitUntil: "networkidle0" }); await sleep(300); + check("after reload (quota used up) the badge shows the local model", (await text(p, "#agentBadge")) === "Local model (local-model)", await text(p, "#agentBadge")); + } + check("no page JS errors", errors.length === 0, errors.join("; ")); await browser.close(); const passed = results.filter(Boolean).length; diff --git a/tests/fake_model_browser.sh b/tests/fake_model_browser.sh index c07543c..ef347d9 100755 --- a/tests/fake_model_browser.sh +++ b/tests/fake_model_browser.sh @@ -10,6 +10,7 @@ start() { # port models rpm burst 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=$4 \ AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18803/v1 AGENT_MARKET_LLM_MODELS=$2 python3 server.py >/dev/null 2>&1 & echo $!; } S1=$(start 18804 mock-model 0 6); S2=$(start 18805 busy-model,busy-model-2 0 6); S3=$(start 18806 daily-model 0 6); S4=$(start 18807 mock-model 1 1) -trap 'kill $M $S1 $S2 $S3 $S4 2>/dev/null; rm -rf $D' EXIT +S5=$(AGENT_MARKET_LOCAL_LLM_URL=http://127.0.0.1:18803/v1 AGENT_MARKET_LOCAL_LLM_MODEL=local-model start 18811 daily-model 0 6) +trap 'kill $M $S1 $S2 $S3 $S4 $S5 2>/dev/null; rm -rf $D' EXIT sleep 1.5 -node tests/fake_model_browser.js http://127.0.0.1:18804 http://127.0.0.1:18805 http://127.0.0.1:18806 http://127.0.0.1:18807 +node tests/fake_model_browser.js http://127.0.0.1:18804 http://127.0.0.1:18805 http://127.0.0.1:18806 http://127.0.0.1:18807 http://127.0.0.1:18811 diff --git a/tests/llm_proxy_check.sh b/tests/llm_proxy_check.sh index a6eb5eb..3f55824 100755 --- a/tests/llm_proxy_check.sh +++ b/tests/llm_proxy_check.sh @@ -9,7 +9,14 @@ start() { # port models rpm 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 \ 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 $!; } S1=$(start 18799 busy-model,mock-model 0); S2=$(start 18800 busy-model,busy-model-2 0); S3=$(start 18801 mock-model 2); S4=$(start 18802 daily-model,mock-model 0) -trap 'kill $M $S1 $S2 $S3 $S4 2>/dev/null; rm -rf $D' EXIT +# local backup model (same fake server standing in for Ollama's OpenAI-compatible endpoint) +local_start() { # port hosted-models local-model timeout [nokey] + local K=$KEYF; [ -n "${5:-}" ] && K=$D/no-such-key + AGENT_MARKET_PORT=$1 AGENT_MARKET_DATA=$D/d$1 AGENT_MARKET_LLM_API_KEY_FILE=$K AGENT_MARKET_CHAT_RPM=0 \ + AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18798/v1 AGENT_MARKET_LLM_MODELS=$2 AGENT_MARKET_LOCAL_LLM_URL=http://127.0.0.1:18798/v1 \ + AGENT_MARKET_LOCAL_LLM_MODEL=$3 AGENT_MARKET_LOCAL_LLM_TIMEOUT=$4 AGENT_MARKET_LOCAL_LLM_MIN=$4 python3 server.py >/dev/null 2>&1 & echo $!; } +S5=$(local_start 18808 daily-model local-model 30); S6=$(local_start 18809 busy-model slow-local 1); S7=$(local_start 18810 x local-model 30 nokey) +trap 'kill $M $S1 $S2 $S3 $S4 $S5 $S6 $S7 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) @@ -51,3 +58,30 @@ assert d["agent"]=="fallback" and d["actions"][0]["type"]=="set_theme" and d["ac print("PASS daily quota -> whole chain parked, built-in agent still restyles:", d["reply"][:80])' curl -s localhost:18802/api/status | python3 -c ' import json,sys; a=json.load(sys.stdin)["agent"]; assert a["daily_quota_exhausted_until_utc"], a; print("PASS status reports quota reset", a["daily_quota_exhausted_until_utc"])' +calls() { curl -s localhost:18798/ | python3 -c "import json,sys;print(json.load(sys.stdin)['calls'].get('$1',0))"; } +C0=$(calls daily-model) +chat 18808 | python3 -c ' +import json,sys; d=json.load(sys.stdin) +assert d["agent"]=="llm" and d.get("local") and d["provider"]=="local" and d["model"]=="local-model", d +assert d["fallbacks"][0]["model"]=="daily-model" and d["fallbacks"][0]["error"]=="daily_quota", d["fallbacks"] +print("PASS hosted daily quota -> local backup model answered (hosted -> local -> rules)")' +chat 18808 | python3 -c ' +import json,sys; d=json.load(sys.stdin); assert d.get("local") and d["fallbacks"]==[{"model":"*","error":"daily_quota","ms":0}], d' +[ "$(calls daily-model)" = "$((C0+1))" ] && echo "PASS quota parked: next turn goes straight to the local model (no hosted call)" || { echo "FAIL hosted called again: $(calls daily-model)"; exit 1; } +curl -s localhost:18808/api/status | python3 -c ' +import json,sys; a=json.load(sys.stdin)["agent"] +assert a["local_model"]=="local-model" and a["local_ok"] is True and a["chain"]==["daily-model","local:local-model"] and a["last_local"] is True and a["daily_quota_exhausted_until_utc"], a +print("PASS status: chain hosted -> local, local server up, last answer local")' +kill $S5; sleep 0.5; S5=$(local_start 18808 daily-model local-model 30); sleep 1.2 +curl -s localhost:18808/api/status | python3 -c ' +import json,sys; a=json.load(sys.stdin)["agent"]; assert a["daily_quota_exhausted_until_utc"], a' +chat 18808 >/dev/null +[ "$(calls daily-model)" = "$((C0+1))" ] && echo "PASS quota stop survives a restart (no call spent to rediscover it)" || { echo "FAIL hosted called after restart"; exit 1; } +chat 18809 | python3 -c ' +import json,sys; d=json.load(sys.stdin) +assert d["agent"]=="fallback" and d["actions"] and any(f["model"]=="slow-local" and f["error"]=="timeout" for f in d["fallbacks"]), d +print("PASS local model over its own timeout -> keyword rules answer (last resort)")' +chat 18810 | python3 -c ' +import json,sys; d=json.load(sys.stdin); assert d["agent"]=="llm" and d.get("local") and d["fallbacks"]==[], d +print("PASS local model works with no hosted key configured")' +curl -s localhost:18810/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}' | grep -q test-dummy-key && { echo "FAIL key"; exit 1; } || true diff --git a/tests/mock_llm.py b/tests/mock_llm.py index 2f00155..d72956a 100755 --- a/tests/mock_llm.py +++ b/tests/mock_llm.py @@ -1,10 +1,10 @@ #!/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 +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} +SEEN = {"auth_ok": 0, "calls": {}} class H(BaseHTTPRequestHandler): def log_message(self, *a): pass @@ -12,6 +12,9 @@ class H(BaseHTTPRequestHandler): 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) diff --git a/tests/theme_check.js b/tests/theme_check.js index bdd9f28..4d75650 100644 --- a/tests/theme_check.js +++ b/tests/theme_check.js @@ -142,8 +142,8 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); check("Reset look button restores the default", rs.ls === null && /just talk to/.test(rs.h) && rs.css === "" && /Look reset to default/.test(rs.toast), JSON.stringify(rs).slice(0, 120)); check("no internal ids in replies", replies.every((x) => !/\b(lst|pi|cash|ord)_[0-9a-f]{6,}/.test(x.reply))); - const who = [...new Set(replies.map((x) => (/AI agent/.test(x.mode) ? x.mode.split(" · ")[1] : "built-in agent")))].join(", "); - if (process.env.REQUIRE_LLM) check("all restyles answered by the real model", replies.every((x) => /AI agent/.test(x.mode)), who); + const who = [...new Set(replies.map((x) => (/AI agent|Local model/.test(x.mode) ? x.mode.split(" · ").slice(0, 2).join(" ") : "built-in agent")))].join(", "); + if (process.env.REQUIRE_LLM) check("all restyles answered by the real model", replies.every((x) => /AI agent|Local model/.test(x.mode)), who); else console.log("answered by: " + who); check("no page JS errors", errors.length === 0, errors.join("; ")); const ms = replies.map((x) => x.ms).sort((a, b) => a - b); diff --git a/web/src/App.tsx b/web/src/App.tsx index d3d4490..122c479 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -50,10 +50,17 @@ export default function App() { }, []); const say = useCallback((role: Msg["role"], text: string) => { const id = ++seq; setMsgs((m) => [...m, { id, role, text }]); return id; }, []); const unsay = useCallback((id: number) => setMsgs((m) => m.filter((x) => x.id !== id)), []); - const setModel = useCallback((model?: string, ms?: number) => { + const setModel = useCallback((model?: string, ms?: number, local = false) => { const short = (model || "").replace(/:free$/, ""); + const secs = ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""; + if (local) { + // the local backup model running on this server (used when the free hosted models are unavailable) + setBadge({ text: `Local model (${short})`, title: `${model} running on this server (local backup; slower than the hosted models)` }); + setChatMode(`Local model · ${short}${secs}`); + return; + } setBadge({ text: "AI: " + short, title: (model || "") + " via OpenRouter (free tier)" }); - setChatMode(`AI agent · ${short}${ms ? " · " + (ms / 1000).toFixed(1) + "s" : ""}`); + setChatMode(`AI agent · ${short}${secs}`); }, []); // Render agent actions through the registry: effects run, panels replace the results area. @@ -91,7 +98,7 @@ export default function App() { if (event) history.current.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) }); history.current.push({ role: "assistant", content: out.reply || "" }); say("bot", out.reply || "(no reply)"); - if (out.agent === "llm") setModel(out.model, out.latency_ms); + if (out.agent === "llm") setModel(out.model, out.latency_ms, !!out.local); else if (out.agent === "fallback") { setChatMode("built-in agent answered (AI model unavailable)"); setBadge({ text: "agent: built-in (AI unavailable)", title: "The hosted model is rate-limited or out of free quota" }); @@ -104,9 +111,12 @@ export default function App() { useEffect(() => { api("/api/status").then((s) => { setFeeBps(s.fee_bps); - if (s.agent.mode === "llm" && s.agent.daily_quota_exhausted_until_utc) - setBadge({ text: "agent: built-in (AI quota used up)", title: "Hosted model free quota resets " + s.agent.daily_quota_exhausted_until_utc }); - else if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model); + const a = s.agent; + if (a.mode === "llm" && a.daily_quota_exhausted_until_utc && a.local_model && a.local_ok !== false) + setBadge({ text: `Local model (${a.local_model})`, title: `Hosted free quota used up (resets ${a.daily_quota_exhausted_until_utc}); a local model on this server answers` }); + else if (a.mode === "llm" && a.daily_quota_exhausted_until_utc) + setBadge({ text: "agent: built-in (AI quota used up)", title: "Hosted model free quota resets " + a.daily_quota_exhausted_until_utc }); + else if (a.mode === "llm") setModel(a.last_model || a.model, undefined, a.last_model ? !!a.last_local : !a.chain?.some((m) => !m.startsWith("local:"))); else setBadge({ text: "agent: rule-based fallback", title: s.agent.note || "" }); }); api("/api/chat", { event: { type: "landing" }, actor }).then((out) => { diff --git a/web/src/types.ts b/web/src/types.ts index 50271cc..d284b74 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -16,11 +16,15 @@ export interface Order { export interface UIAction { type: string; payload?: any } export interface ChatReply { reply?: string; actions?: UIAction[]; agent?: "llm" | "fallback" | "ui-event" | "rate-limit"; model?: string; latency_ms?: number; + provider?: string; local?: boolean; } export interface ChatEvent { type: string; [k: string]: unknown } export interface Status { env: string; fee_bps: number; - agent: { mode: "llm" | "fallback"; model?: string; last_model?: string; note?: string; daily_quota_exhausted_until_utc?: string | null }; + agent: { + mode: "llm" | "fallback"; model?: string; last_model?: string; note?: string; daily_quota_exhausted_until_utc?: string | null; + local_model?: string | null; local_ok?: boolean | null; last_local?: boolean; chain?: string[]; + }; } export const money = (c: number) => "$" + (c / 100).toFixed(2);