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:
leora 2026-10-08 21:11:57 +08:00
parent aacd2b789e
commit 55cccc4d9a
14 changed files with 299 additions and 51 deletions

View File

@ -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`) | | anything else | refused (`not_a_test_card`) |
## LLM configuration ## 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. key stays on the server: it never goes to the browser, never into the repo, and never into a URL.
| Variable | Meaning | | 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 `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: 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: 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. - **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, 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. 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 **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. 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. The landing view doesn't call the model either. Set `AGENT_MARKET_LLM_EVENTS=1` to send events to the LLM as well.

View File

@ -20,6 +20,8 @@ echo "deploying $ENVN @ $(git rev-parse --short HEAD) on :$PORT"
CONF=$HOME/.config/agent-market CONF=$HOME/.config/agent-market
set -a set -a
[ -f "$CONF/llm.env" ] && . "$CONF/llm.env" [ -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" [ -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: # 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 # AM_EXTRA_ENV=/path/offline.env ./deploy.sh staging my-branch

View File

@ -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. component set the frontend knows how to render.
""" """
from __future__ import annotations 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 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%
@ -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") _pool = concurrent.futures.ThreadPoolExecutor(max_workers=8, thread_name_prefix="llm")
COOLDOWN_429 = float(os.environ.get("AGENT_MARKET_LLM_COOLDOWN", "60")) COOLDOWN_429 = float(os.environ.get("AGENT_MARKET_LLM_COOLDOWN", "60"))
_cooldown = {} # model -> unix time until which we skip it after a 429 _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} 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): class LLMError(Exception):
@ -464,31 +496,48 @@ def _read_key():
return None return None
def _llm_config(): def _local_config():
"""Hosted LLM config (OpenAI-compatible chat completions) with an ordered model fallback chain.""" """Local OpenAI-compatible server (no key), e.g. AGENT_MARKET_LOCAL_LLM_URL=http://127.0.0.1:11434/v1."""
if not _read_key(): 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 None
return {"provider": "local", "url": base.rstrip("/") + "/chat/completions", "model": model, "models": [model], "local": True}
def _llm_config():
"""Hosted LLM config (OpenAI-compatible chat completions) with an ordered model fallback chain,
plus an optional local backup model. None when neither is configured."""
remote = None
if _read_key():
base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or "" base = os.environ.get("AGENT_MARKET_LLM_BASE_URL") or ""
if not base: 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 "") 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()] 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: if base and models:
return None remote = {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions",
return {"provider": base.split("//")[-1].split("/")[0], "url": base.rstrip("/") + "/chat/completions",
"models": models, "model": models[0]} "models": models, "model": models[0]}
local = _local_config()
if not remote and not local:
return None
cfg = remote or {"provider": "local", "url": None, "models": [], "model": local["model"]}
cfg["local"] = local
return cfg
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} payload = {"model": model, "messages": messages, "temperature": 0.2, "max_tokens": 1100}
if json_mode: if json_mode:
payload["response_format"] = {"type": "json_object"} payload["response_format"] = {"type": "json_object"} # Ollama maps this to JSON mode
req = urllib.request.Request(cfg["url"], data=json.dumps(payload).encode(), method="POST", headers={ headers = {"Content-Type": "application/json", "X-Title": "Agent Market demo"}
"Authorization": "Bearer " + _read_key(), if cfg.get("local"):
"Content-Type": "application/json", payload["max_tokens"] = 700
"X-Title": "Agent Market demo", 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: 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()) data = json.loads(r.read().decode())
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body = e.read().decode(errors="replace")[:2000] 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("daily_quota", m.group(1) if m else "")
raise LLMError("rate_limited", body[:300]) raise LLMError("rate_limited", body[:300])
if e.code == 400 and json_mode and "response_format" in body: 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]) raise LLMError(f"http_{e.code}", body[:300])
except (urllib.error.URLError, TimeoutError, OSError) as e: 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__) raise LLMError("timeout_or_network", type(e).__name__)
if isinstance(data, dict) and data.get("error"): if isinstance(data, dict) and data.get("error"):
err = data["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) "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] + 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 "{}") + "\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)) "\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"): if h.get("role") in ("user", "assistant") and h.get("content"):
msgs.append({"role": h["role"], "content": str(h["content"])[:600]}) 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) t_end = time.time() + (timeout or LLM_TIMEOUT)
content = _call_llm(cfg, model, msgs, timeout=timeout)
plan = _parse_agent_json(content) plan = _parse_agent_json(content)
tool_results = [] tool_results = []
tools = [t for t in (plan.get("tools") or []) if isinstance(t, dict)][:3] 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}, msgs += [{"role": "assistant", "content": content},
{"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] + {"role": "user", "content": "TOOL_RESULTS:\n" + json.dumps(tool_results)[:6000] +
"\nNow return the final {\"reply\",\"actions\"} JSON only."}] "\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] 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() reply = str(plan.get("reply") or "").strip()
if not reply and not actions: 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=""): 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 = [] errors = []
now_t = time.time() now_t = time.time()
start = now_t
local = cfg.get("local")
if QUOTA["until"] > now_t: if QUOTA["until"] > now_t:
raise LLMError("all_models_failed", json.dumps([{"model": "*", "error": "daily_quota", "ms": 0}])) chain = []
chain = [m for m in cfg["models"] if _cooldown.get(m, 0) <= now_t] or cfg["models"][-1:] errors.append({"model": "*", "error": "daily_quota", "ms": 0})
start = time.time() 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: for model in chain:
t0 = time.time() t0 = time.time()
remaining = LLM_BUDGET - (t0 - start) 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") raise LLMError("timeout", f">{min(LLM_TIMEOUT, remaining):.0f}s")
out.update(agent="llm", provider=cfg["provider"], model=model, out.update(agent="llm", provider=cfg["provider"], model=model,
latency_ms=int((time.time() - t0) * 1000), fallbacks=errors) 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 return out
except LLMError as e: except LLMError as e:
if e.kind == "rate_limited": if e.kind == "rate_limited":
@ -609,11 +670,37 @@ def llm_agent(store, message, history, cfg, look=""):
for mm in cfg["models"]: for mm in cfg["models"]:
_cooldown[mm] = max(_cooldown.get(mm, 0), until) _cooldown[mm] = max(_cooldown.get(mm, 0), until)
QUOTA["until"] = until QUOTA["until"] = until
_save_quota()
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)}) errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
break break
errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)}) errors.append({"model": model, "error": e.kind, "ms": int((time.time() - t0) * 1000)})
except Exception as e: # never let the page break except Exception as e: # never let the page break
errors.append({"model": model, "error": type(e).__name__, "ms": int((time.time() - t0) * 1000)}) 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()) LAST.update(error=errors[-1]["error"] if errors else "no_model", at=time.time())
raise LLMError("all_models_failed", json.dumps(errors)) 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 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(): def agent_status():
cfg = _llm_config() cfg = _llm_config()
if cfg: if cfg:
now_t = time.time() 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], "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} "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, 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."} "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

@ -260,12 +260,15 @@ def build_theme_action(payload):
"""Turn a model's set_theme payload into a client action: {reset?, base?, patch}.""" """Turn a model's set_theme payload into a client action: {reset?, base?, patch}."""
if not isinstance(payload, dict): if not isinstance(payload, dict):
return None return None
if payload.get("reset"):
return {"reset": True, "patch": sanitize_patch(payload.get("patch") or {})}
name = str(payload.get("preset") or "").strip().lower() 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) 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")} 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)} out = {"patch": sanitize_patch(patch)}
if payload.get("reset"):
out["reset"] = True
if name in PRESETS: if name in PRESETS:
out["preset"] = name out["preset"] = name
out["base"] = sanitize_patch(PRESETS[name]) out["base"] = sanitize_patch(PRESETS[name])

34
ops/ollama-watchdog.sh Executable file
View 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

View File

@ -147,6 +147,10 @@ def hydrate(actions, actor):
continue continue
deal = r["deal"] deal = r["deal"]
p.update(deal=deal, listing=store.get("listings", deal["listing_id"]), deal_id=deal["id"]) 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": elif t == "set_theme":
act = TH.build_theme_action(p) act = TH.build_theme_action(p)
if not act: if not act:

View File

@ -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("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]"))); 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); 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) // ask(): type into the chat popup and wait for the agent's reply (LLM replies take a few seconds)
const ask = async (text) => { const ask = async (text) => {

View File

@ -2,7 +2,7 @@
// texts, quota badge, rate-limit toast, and a model-sent set_theme that the server sanitised. No real model calls. // 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> // 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 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 results = [];
const check = (name, ok, d = "") => { results.push(!!ok); console.log((ok ? "PASS " : "FAIL ") + name + (d ? ` [${d}]` : "")); }; 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)); 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)); 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("|")); 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("; ")); check("no page JS errors", errors.length === 0, errors.join("; "));
await browser.close(); await browser.close();
const passed = results.filter(Boolean).length; const passed = results.filter(Boolean).length;

View File

@ -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_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 $!; } 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) 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 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

View File

@ -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_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 $!; } 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) 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 sleep 1.5
chat() { curl -s localhost:$1/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}'; } chat() { curl -s localhost:$1/api/chat -H 'Content-Type: application/json' -d '{"message":"show me bikes"}'; }
OUT=$(chat 18799) 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])' print("PASS daily quota -> whole chain parked, built-in agent still restyles:", d["reply"][:80])'
curl -s localhost:18802/api/status | python3 -c ' 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"])' 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

View File

@ -1,10 +1,10 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Fake OpenAI-compatible endpoint to exercise the LLM proxy path without a real key. """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.""" 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 from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8798 PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8798
SEEN = {"auth_ok": 0} SEEN = {"auth_ok": 0, "calls": {}}
class H(BaseHTTPRequestHandler): class H(BaseHTTPRequestHandler):
def log_message(self, *a): pass 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) 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"])))
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"): if body.get("model") in ("busy-model", "busy-model-2"):
b = b'{"error":{"message":"rate-limited upstream","code":429}}' 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) self.send_response(429); self.send_header("Content-Type", "application/json"); self.end_headers(); self.wfile.write(b)

View File

@ -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("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))); 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(", "); 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/.test(x.mode)), who); 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); else console.log("answered by: " + who);
check("no page JS errors", errors.length === 0, errors.join("; ")); check("no page JS errors", errors.length === 0, errors.join("; "));
const ms = replies.map((x) => x.ms).sort((a, b) => a - b); const ms = replies.map((x) => x.ms).sort((a, b) => a - b);

View File

@ -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 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 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 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)" }); 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. // 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) }); if (event) history.current.push({ role: "user", content: "UI_EVENT " + JSON.stringify(event) });
history.current.push({ role: "assistant", content: out.reply || "" }); history.current.push({ role: "assistant", content: out.reply || "" });
say("bot", out.reply || "(no 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") { else if (out.agent === "fallback") {
setChatMode("built-in agent answered (AI model unavailable)"); 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" }); 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(() => { useEffect(() => {
api<Status>("/api/status").then((s) => { api<Status>("/api/status").then((s) => {
setFeeBps(s.fee_bps); setFeeBps(s.fee_bps);
if (s.agent.mode === "llm" && s.agent.daily_quota_exhausted_until_utc) const a = s.agent;
setBadge({ text: "agent: built-in (AI quota used up)", title: "Hosted model free quota resets " + s.agent.daily_quota_exhausted_until_utc }); if (a.mode === "llm" && a.daily_quota_exhausted_until_utc && a.local_model && a.local_ok !== false)
else if (s.agent.mode === "llm") setModel(s.agent.last_model || s.agent.model); 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 || "" }); else setBadge({ text: "agent: rule-based fallback", title: s.agent.note || "" });
}); });
api<ChatReply>("/api/chat", { event: { type: "landing" }, actor }).then((out) => { api<ChatReply>("/api/chat", { event: { type: "landing" }, actor }).then((out) => {

View File

@ -16,11 +16,15 @@ export interface Order {
export interface UIAction { type: string; payload?: any } export interface UIAction { type: string; payload?: any }
export interface ChatReply { export interface ChatReply {
reply?: string; actions?: UIAction[]; agent?: "llm" | "fallback" | "ui-event" | "rate-limit"; model?: string; latency_ms?: number; 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 ChatEvent { type: string; [k: string]: unknown }
export interface Status { export interface Status {
env: string; fee_bps: number; 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); export const money = (c: number) => "$" + (c / 100).toFixed(2);