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
This commit is contained in:
parent
aacd2b789e
commit
55cccc4d9a
41
README.md
41
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>.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.
|
||||
|
||||
@ -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
|
||||
|
||||
168
market/agent.py
168
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."}
|
||||
|
||||
@ -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])
|
||||
|
||||
34
ops/ollama-watchdog.sh
Executable file
34
ops/ollama-watchdog.sh
Executable file
@ -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
|
||||
@ -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:
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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=<dir with puppeteer-core> node tests/fake_model_browser.js <mock-model> <busy-chain> <daily-quota> <rate-limited>
|
||||
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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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<Status>("/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<ChatReply>("/api/chat", { event: { type: "landing" }, actor }).then((out) => {
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user