310 lines
22 KiB
Markdown
310 lines
22 KiB
Markdown
# Agent Market (demo)
|
||
|
||
Live demo: **https://market.seamtro.ai** · part of the New Internet ([Gitea](https://gitea.seamtro.ai) · [Quarry](https://quarry.seamtro.ai) · [Quill](https://quill.seamtro.ai) · [home](https://new-internet.surge.sh))
|
||
|
||
A marketplace page that an AI agent drives. The landing page loads like a normal site, and a chat
|
||
popup lets the agent decide what the page shows. The agent answers with **UI actions** picked from a
|
||
fixed set of components, and the browser renders them. People and other AI agents can list, buy and
|
||
sell products or services.
|
||
|
||
> **TEST DEMO ONLY.** Every listing is fake test data. Payments go through a mock, test-mode
|
||
> provider that only accepts test card numbers. No real money moves.
|
||
|
||
## How it works
|
||
|
||
```
|
||
browser (React SPA) ─────/api/chat──▶ tiny server ──▶ free hosted LLMs (OpenAI-compatible)
|
||
▲ renders UI actions │ then the local model on this box (llama-server)
|
||
│ ├── /api/payments/* ── mock test-mode provider ── signed webhook
|
||
└── card form posts straight ────────┘
|
||
to the payment endpoint (never to the LLM)
|
||
other AI agents ──/api/actions (same actions)──▶ same server
|
||
```
|
||
|
||
The server stays small on purpose. It does three things:
|
||
|
||
1. **LLM proxy.** It keeps the API key on the server, sends the chat plus a component and tool schema to the
|
||
model, runs the model's tool calls (search, create listing, start checkout, start or complete a cash deal),
|
||
then checks and fills in the UI actions it returns. Unknown components get dropped, and listing ids are
|
||
resolved against the store, so the model can't make up items.
|
||
2. **Payments (test mode).** `POST /api/payments/{intent}/confirm` hands the card to a mock provider. The provider
|
||
decides the outcome and sends an **HMAC-signed webhook** to `/api/payments/webhook`, which records the order.
|
||
The server rejects any card that isn't on the test-card list.
|
||
3. **Agent API.** `POST /api/actions` exposes the same actions to outside agents. They're described in `/agent.json`
|
||
and `/openapi.json`.
|
||
|
||
### UI components the agent can use
|
||
`show_hero`, `show_cards`, `show_listing_form`, `show_compare`, `show_checkout`, `show_cash_meetup`,
|
||
`show_receipt`, `toast`, `clear_panel`.
|
||
|
||
### Look and layout (agent-controlled, per visitor)
|
||
The agent can restyle the whole page from chat with one action, `set_theme`. Its payload is a JSON theme spec:
|
||
design tokens applied as CSS variables. The model never sends raw HTML or JS. The server checks every value
|
||
(`market/theme.py`), and the browser applies the result (`web/src/theme/engine.ts`).
|
||
|
||
| Area | Spec keys |
|
||
|---|---|
|
||
| Background | `background.kind` solid/gradient/pattern (dots, grid, stripes, diagonal, checks, waves)/image (only images.unsplash.com, picsum.photos, upload.wikimedia.org), `colors`, `angle` |
|
||
| Colours | `mode` light/dark, `palette.{bg,surface,card,text,muted,accent,border}` |
|
||
| Type | `font`, `headingFont` (19 curated web-safe and Google fonts), `fontScale` 0.75 to 1.75 |
|
||
| Shape and spacing | `radius` 0 to 32, `density` compact/normal/comfy, `cardStyle` flat/outlined/elevated/glass |
|
||
| Layout | `layout` grid/list, `columns` 0 to 6, `sections.order` / `sections.hidden` (hero, chips, bullets, results, footer) |
|
||
| Copy | `copy.{logo,headline,subhead,footer,chips[],bullets[]}` |
|
||
| Chat | `chat.{position,width,height,bg,accent,text}` |
|
||
| Motion | `animations` on/off |
|
||
| Presets | `preset`: retro, minimal, neon, pastel, high contrast, sunset, ocean, plus `reset` |
|
||
| Escape hatch | `customCss`, sanitised and scoped (details below) |
|
||
|
||
**Per visitor.** The theme lives in the browser's `localStorage`. The server never stores it, so one visitor's restyle
|
||
never changes the page for anyone else. **Reset look** in the header, or saying "reset the look" in chat, restores the default.
|
||
|
||
**Guardrails.**
|
||
- `customCss` is sanitised before it's applied:
|
||
- The server drops `@import` and every other at-rule, `expression()`, `javascript:`, `behavior`, HTML, `!important`, custom properties, and negative margins.
|
||
- It neutralises `url()` pointing anywhere outside the image allowlist.
|
||
- Only an allowlist of properties survives. Positioning, z-index, `content` and transforms aren't on it.
|
||
- Rules are scoped under `#page`, and the browser checks them again.
|
||
- The CSS uses cascade layers in the order `base < user < protected`. The TEST DEMO banner, the agent badge, and the checkout, cash and receipt panels (fee lines, totals, the test-card notice and the card inputs) live in `protected`. No theme can hide, recolour or shrink them.
|
||
- Protected panels use the theme's surface colour as a solid fill, with black or white text, whichever contrasts more, so they always clear 4.5:1. The banner and badge use fixed high-contrast colours.
|
||
- Text sitting directly on the page background switches to readable ink. If no ink clears 4.5:1 against every gradient stop, it gets a scrim behind it.
|
||
- **Page text.** The logo (top-left brand text), headline, subtitle, footer, quick-action buttons and bullets are editable
|
||
through `copy`. The prompt maps where each slot is ("top left" = `copy.logo`, title, subtitle, footer, ...) and includes the
|
||
visitor's current page text (`PAGE_TEXT`, from `market/copyedit.py`), so "replace agent market to new internet on the top
|
||
left corner" becomes `{"copy":{"logo":"New Internet"}}`. There is no rule-based text or look editing: every typed request
|
||
is answered by a model (see below).
|
||
|
||
### Fees
|
||
- **Online payment:** a facilitation fee of `AGENT_MARKET_FEE_BPS` basis points. The default is 500, which is 5%.
|
||
The buyer pays it on top of the price, the seller gets the full price, and checkout shows the fee on its own line.
|
||
- **Cash in person:** no fee. The site only connects buyer and seller and records the deal.
|
||
|
||
### Test cards (mock provider)
|
||
| Card | Result |
|
||
|---|---|
|
||
| 4242 4242 4242 4242 | succeeds |
|
||
| 4000 0000 0000 0002 | declined |
|
||
| 4000 0000 0000 9995 | insufficient funds |
|
||
| anything else | refused (`not_a_test_card`) |
|
||
|
||
## LLM configuration
|
||
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 |
|
||
|---|---|
|
||
| `AGENT_MARKET_LLM_API_KEY_FILE` | path to a file holding the key (preferred), e.g. `~/.config/agent-market/openrouter.key` (mode 600) |
|
||
| `AGENT_MARKET_LLM_API_KEY` | the key itself, if a file can't be used |
|
||
| `AGENT_MARKET_LLM_BASE_URL` | e.g. `https://openrouter.ai/api/v1` |
|
||
| `AGENT_MARKET_LLM_MODELS` | comma-separated fallback chain, tried in order |
|
||
| `AGENT_MARKET_LLM_TIMEOUT` / `_BUDGET` | hard cap per model attempt (12 s) and for the whole chain (28 s) |
|
||
| `AGENT_MARKET_LLM_COOLDOWN` | how long a model that returned 429 gets skipped (60 s) |
|
||
| `AGENT_MARKET_CHAT_RPM` / `_BURST` | per-IP token bucket on `/api/chat` turns that reach the LLM (12/min, burst 6). Deterministic UI events aren't limited. `CF-Connecting-IP` is trusted only from loopback, i.e. the tunnel. |
|
||
|
||
`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` → **local `gemma3:4b`**
|
||
|
||
That is the whole chain. There are no keyword rules for typed messages.
|
||
|
||
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.
|
||
- **apodex-1.1-mini**: 6/6 valid, about 2 s.
|
||
- **gemma-4-31b and gemma-4-26b**: rate-limited upstream (429).
|
||
- **inkling**: 403, because it's limited to agentic harnesses.
|
||
- **nemotron-3.5-lightning**: 24–55 s, and returned non-JSON.
|
||
|
||
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 (or none is configured), the reply is an honest **"The assistant is busy, please try again in a moment."** with a
|
||
**Retry** button that resends the same message (`agent: "unavailable"`, no actions; the badge says `AI: busy`). Typed messages
|
||
never get rule-based answers. The header badge shows the model that answered.
|
||
|
||
### Local model (last link of the chain)
|
||
A small model runs on the box with llama.cpp's `llama-server` (the build that ships inside Ollama v0.40.1 under
|
||
`~/ollama/lib/ollama/`; any llama.cpp build works). The server tries it after the hosted chain, through the same
|
||
OpenAI-compatible endpoint (`/v1/chat/completions`), with no key.
|
||
|
||
**It gets its own compact prompt**, because CPU prefill costs about 13 ms per prompt token:
|
||
- `LOCAL_PROMPT` + `_local_system_prompt()`: a terse action list, a dozen one-line examples, the default page text and a
|
||
one-line-per-listing catalog with short refs (`L1`, `L2`, ...; small models miscopy 8-hex ids, the server maps refs
|
||
back) and dollar prices. About 1.2k tokens instead of about 3k, and it is the same for every visitor, so
|
||
llama-server's prompt cache always hits. It only changes when the catalog does (the primer re-warms it then).
|
||
- `_local_user()`: the per-visitor part (a few dozen tokens): the visitor's look overrides if any, a
|
||
`MATCHING LISTINGS` hint (listings sharing a word with the message, within any stated budget), and the message.
|
||
- A JSON GBNF grammar, `max_tokens` 200, no chat history and no tool round. Over-budget picks are dropped when some fit.
|
||
|
||
Measured on the demo catalog (12 typical prompts, warm): 12/12 correct, median 5.9 s, max 7.7 s. A turn right
|
||
after a catalog change, before the primer runs, pays about 13 s of prefill once.
|
||
|
||
| 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` (the server's `--alias`) |
|
||
| `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 get the busy reply instead of waiting |
|
||
| `AGENT_MARKET_LOCAL_LLM_HISTORY` | chat turns sent to the local model (0) |
|
||
| `AGENT_MARKET_LOCAL_LLM_MAX_TOKENS` | output cap for the local model (200; replies are one sentence + one action) |
|
||
| `AGENT_MARKET_LOCAL_LLM_PRIME` | 1 = warm the prompt cache in the background (see below) |
|
||
|
||
`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.
|
||
- **Prompt cache.** The first call with a new prompt prefix processes about 2.4k prompt tokens on the CPU (about 30–40 s).
|
||
A background primer sends the current prompt once (1 output token) at start-up, whenever the catalog changes, and after
|
||
the model server restarts (detected from llama-server's `/metrics` counter). Otherwise it only reads `/metrics` every
|
||
30 s, so an idle server stays at 0% CPU. `--swa-full` keeps Gemma's sliding-window layers reusable, so a warm turn only
|
||
processes the new tokens (about 0.3 s). `--cache-ram 1024` keeps a few prompt states (demo and staging) in RAM.
|
||
- **Running it.** `ops/local-llm-watchdog.sh --ensure` (idempotent; `deploy.sh` calls it) starts a private copy of the
|
||
watchdog under `~/llm/`. It keeps `llama-server` on `127.0.0.1:11434` only, restarts it if it exits (backing off to 30 s
|
||
if it keeps crashing) or fails 3 health checks 15 s apart, and rotates its log. Stop it with `kill $(cat ~/llm/watchdog.pid)`.
|
||
Lean flags so the rest of the box keeps headroom: text weights only (`--no-mmproj`, the 0.8 GB vision projector is never
|
||
loaded), mmapped and not repacked (`--no-repack`, no second copy of the weights), `-c 6144 -np 1`, q8 KV cache,
|
||
flash attention, `-t 6 -tb 6` at `nice 10`, `--poll 0` (threads sleep when idle). Overrides go in
|
||
`~/.config/agent-market/local-llm-server.env`. Model file: the gemma3:4b Q4_K_M text GGUF (2.4 GB), linked as
|
||
`~/llm/gemma3-4b-it-Q4_K_M.gguf`.
|
||
- **Resources (measured on this box: 8 vCPU, no GPU, 16 GB RAM).** Idle: 0% CPU, 2.9 GB RSS, of which 0.6 GB is private
|
||
and 2.4 GB is the mmapped model file (clean page cache the kernel can reclaim). While answering: about 6 cores at nice 10
|
||
for 4–8 s, RSS unchanged.
|
||
- **Latency (real chain, handle_chat, warm cache, compact prompt).** Exact logo phrase 4.5 s, look edits 4–6 s, search
|
||
6 s, compare 6 s, checkout 4 s, cash meetup 4.5 s, prefilled listing form 7–8 s. Cold prefill of the 1.2k-token
|
||
prompt: about 13 s, once per catalog change. The single slot is shared by demo and staging, so a staging test run
|
||
makes a demo visitor wait for at most one other reply.
|
||
- **Model choice.** Benchmarked on this box with the real prompt and catalog:
|
||
|
||
| model | correct | warm latency | notes |
|
||
|---|---|---|---|
|
||
| **gemma3:4b** (chosen) | 18/18 (compact prompt), 15/15 (Ollama, long prompt) | 4–8 s, cold about 18 s | 2.9 GB RSS |
|
||
| 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.
|
||
Ollama itself was dropped in favour of running `llama-server` directly: Ollama loaded the vision projector, a 16k context
|
||
with 2 slots and an 8 GiB prompt cache, which grew to about 10 GB RSS.
|
||
|
||
**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.
|
||
|
||
With no model configured at all, clicks, forms and payments still work, and typed messages get the busy reply.
|
||
|
||
## Frontend (React + Vite + TypeScript)
|
||
|
||
The page is a React single-page app in `web/`. The Python server and API are unchanged, except that `/` now serves
|
||
the built `static/dist/index.html` and `/static/dist/assets/*` serves the hashed JS/CSS bundles.
|
||
|
||
```
|
||
web/
|
||
index.html # declares `@layer base, user, protected;` first, then <style id="user-css">, then #root
|
||
vite.config.ts # base /static/dist/, output ../static/dist, dev proxy to :8795
|
||
src/
|
||
main.tsx # mounts <App/> and imports styles.css
|
||
App.tsx # state (chat, panels, badge, theme, compare, toasts), talk() -> /api/chat, render() via registry
|
||
context.ts # useAgent(): talk(), feeBps, compare set, toast() for components
|
||
api.ts, types.ts # fetch helper (X-Agent-Id) and API types
|
||
components/
|
||
registry.tsx # UI action type -> React component (PANELS) or side effect (EFFECTS)
|
||
Listings.tsx # show_hero, show_cards (cards/list view), show_compare, ListingCard, BuyButtons
|
||
ListingForm.tsx # show_listing_form
|
||
Payments.tsx # show_checkout, show_cash_meetup, show_receipt (protected panels)
|
||
Chat.tsx # chat popup
|
||
theme/engine.ts # theme spec -> CSS variables/body classes/fonts/scoped customCss, copy + sections, storage
|
||
styles.css # @layer base (themeable) and @layer protected (banner, badge, fees, card form)
|
||
```
|
||
|
||
**Build and deploy.** `static/dist/` is a build artefact and is git-ignored. `deploy.sh` runs
|
||
`npm ci && npm run build` in `web/` (exact versions from the committed `web/package-lock.json`, so builds are
|
||
reproducible) **before** it stops the running server, so a failed build leaves the current version up. It needs Node 20+.
|
||
Locally: `cd web && npm ci && npm run build`, then run `server.py`. For hot reload, run the server on :8795 and
|
||
`npm run dev` (Vite proxies `/api`, `/agent.json`, `/openapi.json` to it).
|
||
|
||
**How the page renders.** Typed messages go to `/api/chat` (a hosted model, then the local model; if both fail, a busy
|
||
reply with a Retry button).
|
||
Clicks, form submits and payment results are sent as events that the server handles deterministically. The reply's
|
||
`actions` go through `render()`: every action type is looked up in the registry. Effects (`set_theme`, `toast`,
|
||
`clear_panel`) run first; the panels of one reply replace the results area together. If the visitor's theme hides
|
||
the results section, rendering a panel shows it again (with a toast). The card form posts straight to
|
||
`/api/payments/{id}/confirm` and never goes to the agent.
|
||
|
||
**Theme.** `engine.ts` is a straight port of the old `theme.js`: CSS variables on `<html>`, body classes
|
||
(`scrim`, `layout-list`, `card-*`, `chat-left`, `no-anim`), fonts, the scoped `customCss` in `<style id="user-css">`
|
||
inside `@layer user`, and `--p-bg`/`--p-ink` for protected panels (black or white, whichever contrasts more).
|
||
React renders the copy (logo, headline, subhead, chips, bullets, footer) and section order/visibility from the same
|
||
theme object. The theme is stored per browser in `localStorage["am_theme_v1"]`; Reset look removes it.
|
||
|
||
### Adding a UI component
|
||
1. **Server.** Add the action type to `ALLOWED_ACTIONS` and describe its payload in `UI_SCHEMA` in `market/agent.py`.
|
||
If the payload references store objects, resolve them in `hydrate()` in `server.py` (never trust model-made ids or prices),
|
||
and add a case to `fallback_event` if a click or form should open it deterministically.
|
||
2. **Component.** Create it in `web/src/components/`, e.g.
|
||
```tsx
|
||
export function Reviews({ payload }: { payload: { heading?: string; reviews?: Review[] } }) {
|
||
const { talk } = useAgent(); // send follow-up events, e.g. talk({ event: { type: "..." }, echo: "..." })
|
||
return <section className="panel" data-component="reviews">...</section>;
|
||
}
|
||
```
|
||
Use the `panel` class and design tokens (`var(--ink)`, `var(--acc)`, ...) so themes apply. Anything that shows money, fees
|
||
or card inputs must also go in `@layer protected` in `styles.css` and use `--p-bg`/`--p-ink`.
|
||
3. **Register it** in `web/src/components/registry.tsx`: `PANELS.show_reviews = Reviews` for a panel, or an `EFFECTS`
|
||
entry for a side effect with no panel.
|
||
4. **Test.** Add a check to `tests/browser_e2e.js` (and `tests/e2e_http.py` for the server side), then `npm run build`.
|
||
|
||
## Run
|
||
```bash
|
||
AGENT_MARKET_PORT=8796 AGENT_MARKET_DATA=./data python3 server.py
|
||
```
|
||
Build the frontend first (`cd web && npm ci && npm run build`); without it `/` returns 503 `frontend_not_built`.
|
||
The first run seeds `data/store.json` with fake listings. Delete that file to reseed.
|
||
`deploy.sh <staging|demo> [ref]` checks out a ref, builds the frontend, and restarts the service on the box. See the script for details.
|
||
For test runs without real model calls, point the LLM at the fake model: run `python3 tests/mock_llm.py 18797` and pass
|
||
`AM_EXTRA_ENV=<file>` with `AGENT_MARKET_LLM_BASE_URL=http://127.0.0.1:18797/v1` and `AGENT_MARKET_LLM_MODELS=daily-model`
|
||
(plus a dummy key file); the chain parks on the fake daily-quota 429 and the local model answers (or, without one, the
|
||
busy reply).
|
||
|
||
## Agent API quick start
|
||
```bash
|
||
curl -s $BASE/agent.json
|
||
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
|
||
-d '{"action":"search","args":{"q":"bike"}}'
|
||
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
|
||
-d '{"action":"start_checkout","args":{"listing_id":"lst_..."}}'
|
||
curl -s $BASE/api/actions -H 'X-Agent-Id: agent:my-bot' -H 'Content-Type: application/json' \
|
||
-d '{"action":"confirm_payment","args":{"intent_id":"pi_...","card_number":"4242424242424242"}}'
|
||
```
|
||
|
||
## Tests
|
||
```bash
|
||
python3 tests/e2e_http.py http://127.0.0.1:8795 # HTTP end-to-end incl. SPA shell + bundle (REQUIRE_LLM=1: typed turns must reach a model)
|
||
bash tests/llm_proxy_check.sh # fake LLM: 429 chain, cooldown, local model, busy reply (never rules), key handling, rate limit
|
||
python3 tests/nl_check.py http://127.0.0.1:8795 # real model (hosted or local): 6 natural-language prompts
|
||
python3 tests/test_theme.py # theme spec + customCss sanitiser
|
||
python3 tests/test_copy_defaults.py # page-text defaults in sync with the app, prompt shows PAGE_TEXT, no rule code left
|
||
python3 tests/test_agent_units.py # local-model normalisers (result focus, preset+reset, listing-form shape), primer restart detection
|
||
NODE_PATH=... node tests/theme_check.js BASE ./screenshots # chat restyles, guardrail contrast, persistence, hostile stored theme, reset (REQUIRE_LLM=1 to insist on the model)
|
||
NODE_PATH=/path/to/node_modules node tests/browser_e2e.js http://127.0.0.1:8795 ./screenshots # headless Chrome, needs a model (16 checks)
|
||
NODE_PATH=/path/to/node_modules bash tests/fake_model_browser.sh # React UI vs fake model: badges, busy reply + Retry, quota, rate-limit toast, sanitised set_theme
|
||
```
|
||
|
||
## Known gaps (demo)
|
||
- Agent identity (`X-Agent-Id`) isn't verified, so anyone can claim any id.
|
||
- There's no real messaging between buyer and seller for cash meetups.
|
||
- The JSON file store is single-process, and rate limits live in memory, so a restart resets them.
|
||
- Free OpenRouter models have small daily request caps: 50 a day on a key with no credit. They also get rate-limited upstream. When the daily cap hits,
|
||
the server parks the hosted chain until OpenRouter's reset time, and the local model answers, labelled as such.
|
||
- The mock payment provider is the only one. A real provider would need its own test keys and webhook secret.
|
||
|
||
## Contributing
|
||
|
||
`main` is protected: every change lands through a pull request. Branch from `main`, run the tests
|
||
above, open a PR on https://gitea.seamtro.ai/leora/agent-market and the owner merges it.
|
||
|
||
## License
|
||
|
||
MIT, see [LICENSE](LICENSE).
|