From 8f7bab20462ed1bae41b2a04b2e99de1faa9ca46 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:14:03 +0000 Subject: [PATCH 01/14] Add api.allerion.io AI gateway (LiteLLM + Caddy, NVIDIA proxy) Self-contained docker-compose stack that fronts NVIDIA's hosted OpenAI-compatible API under api.allerion.io. LiteLLM handles virtual keys, rate limits, and budgets; Caddy terminates TLS; Postgres stores keys and spend. Includes config, env template, and a setup/hardening README with an upgrade path to self-hosted GPU nodes. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- infra/ai-gateway/.env.example | 17 ++++ infra/ai-gateway/.gitignore | 3 + infra/ai-gateway/Caddyfile | 34 ++++++++ infra/ai-gateway/README.md | 112 +++++++++++++++++++++++++++ infra/ai-gateway/docker-compose.yml | 65 ++++++++++++++++ infra/ai-gateway/litellm/config.yaml | 68 ++++++++++++++++ 6 files changed, 299 insertions(+) create mode 100644 infra/ai-gateway/.env.example create mode 100644 infra/ai-gateway/.gitignore create mode 100644 infra/ai-gateway/Caddyfile create mode 100644 infra/ai-gateway/README.md create mode 100644 infra/ai-gateway/docker-compose.yml create mode 100644 infra/ai-gateway/litellm/config.yaml diff --git a/infra/ai-gateway/.env.example b/infra/ai-gateway/.env.example new file mode 100644 index 0000000..d8bd271 --- /dev/null +++ b/infra/ai-gateway/.env.example @@ -0,0 +1,17 @@ +# Copy to .env and fill in. NEVER commit the real .env (see .gitignore). + +# Your NVIDIA hosted-API key from https://build.nvidia.com (server-side only). +NVIDIA_API_KEY=nvapi-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx + +# The LiteLLM admin/master key. Use it ONLY to mint virtual keys and admin — +# do not hand it to client apps. Generate a strong random value, e.g.: +# openssl rand -hex 32 -> prefix with "sk-" +LITELLM_MASTER_KEY=sk-replace-with-strong-random + +# Used to encrypt secrets stored in the DB. Generate once and keep stable: +# openssl rand -hex 32 +LITELLM_SALT_KEY=replace-with-strong-random + +# Postgres password for the bundled DB. +# openssl rand -hex 24 +POSTGRES_PASSWORD=replace-with-strong-random diff --git a/infra/ai-gateway/.gitignore b/infra/ai-gateway/.gitignore new file mode 100644 index 0000000..e49668b --- /dev/null +++ b/infra/ai-gateway/.gitignore @@ -0,0 +1,3 @@ +.env +*.env +!.env.example diff --git a/infra/ai-gateway/Caddyfile b/infra/ai-gateway/Caddyfile new file mode 100644 index 0000000..a4df5fd --- /dev/null +++ b/infra/ai-gateway/Caddyfile @@ -0,0 +1,34 @@ +# Caddy auto-provisions a Let's Encrypt TLS cert for api.allerion.io as soon +# as the A/AAAA record points here and ports 80/443 are reachable. +# +# Set an ACME contact email (optional but recommended) by replacing the +# global block email below, or via the CADDY_ACME_EMAIL build arg / env. + +{ + # email you@allerion.io +} + +api.allerion.io { + encode zstd gzip + + # LiteLLM streams SSE for chat completions; Caddy handles this natively. + reverse_proxy litellm:4000 { + # Give long-running / streaming completions room to finish. + transport http { + read_timeout 600s + write_timeout 600s + } + } + + # Basic hardening headers. + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + -Server + } + + log { + output stdout + format console + } +} diff --git a/infra/ai-gateway/README.md b/infra/ai-gateway/README.md new file mode 100644 index 0000000..2dc1c71 --- /dev/null +++ b/infra/ai-gateway/README.md @@ -0,0 +1,112 @@ +# Allerion AI Gateway (`api.allerion.io`) + +An OpenAI-compatible API gateway that fronts **NVIDIA's hosted model API** +under your own domain. Clients talk to `https://api.allerion.io`; the gateway +authenticates them with **your** virtual keys, swaps in the NVIDIA key +server-side, and enforces rate limits and budgets. + +``` +client ──TLS──> api.allerion.io ──> Caddy ──> LiteLLM ──> integrate.api.nvidia.com/v1 + │ + Postgres (virtual keys, spend) +``` + +> **This is a proxy, not self-hosting.** Inference runs on NVIDIA's GPUs. It is +> the fastest path to a branded endpoint and the right first step. When you add +> your own GPU nodes later, register them in `litellm/config.yaml` and nothing +> else changes — same URL, same client keys. See the upgrade-path comments in +> that file. + +## Stack + +| Component | Role | +|-----------|------| +| **Caddy** | TLS termination (auto Let's Encrypt) + reverse proxy | +| **LiteLLM** | OpenAI-compatible proxy, virtual keys, rate limits, budgets, logging | +| **Postgres** | Stores virtual keys, teams, and spend | + +No GPU required. + +## Prerequisites + +1. A VPS (1 vCPU / 1–2 GB RAM is plenty — this only proxies) with Docker + Docker Compose. +2. Ports **80** and **443** open to the internet. +3. An NVIDIA API key from . +4. DNS: an `A` (and `AAAA` if you have IPv6) record for **`api.allerion.io`** + pointing at the VPS IP. Caddy needs this resolving before it can issue a cert. + +## Setup + +```bash +cd infra/ai-gateway +cp .env.example .env +# Fill in NVIDIA_API_KEY and generate the secrets: +# openssl rand -hex 32 (for LITELLM_MASTER_KEY -> prefix sk-, and SALT_KEY) +# openssl rand -hex 24 (for POSTGRES_PASSWORD) +$EDITOR .env + +# (optional) set your ACME email in the Caddyfile global block + +docker compose up -d +docker compose logs -f caddy # watch the TLS cert get issued +``` + +Once Caddy reports a valid certificate, the gateway is live at +`https://api.allerion.io`. + +## Mint a client key (don't share the master key) + +The master key is admin-only. Issue scoped virtual keys per app/team: + +```bash +curl https://api.allerion.io/key/generate \ + -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "models": ["deepseek-r1", "llama-3.3-70b"], + "max_budget": 25, + "budget_duration": "30d", + "rpm_limit": 60 + }' +``` + +That returns an `sk-...` key safe to give to a client. + +## Use it (drop-in OpenAI client) + +```bash +curl https://api.allerion.io/v1/chat/completions \ + -H "Authorization: Bearer sk-the-virtual-key" \ + -H "Content-Type: application/json" \ + -d '{ + "model": "deepseek-r1", + "messages": [{"role": "user", "content": "Hello from allerion.io"}] + }' +``` + +Any OpenAI SDK works — set `base_url="https://api.allerion.io/v1"` and the +virtual key as the API key. + +## Models + +Edit `litellm/config.yaml`. The `model_name` is the alias clients call; the +`model:` value must match an exact ID from . +Verify IDs against the catalog and your entitlement before enabling them. + +## Hardening checklist + +- [ ] `.env` is git-ignored (it is) and never committed. +- [ ] Master key used only for admin / key minting, never in client apps. +- [ ] Every client uses a scoped virtual key with `max_budget` + `rpm_limit`. +- [ ] A global `max_budget` is set in `config.yaml` as a backstop. +- [ ] VPS firewall allows only 80/443 (and your SSH). +- [ ] Review NVIDIA's `build.nvidia.com` / NGC terms before exposing the + endpoint to third parties or commercial users — proxying their hosted + API to others may be restricted. + +## Upgrade path → real sovereign AI + +To make this genuinely self-hosted, stand up NIM or vLLM on your own GPU nodes +and add them to `model_list` (see the commented `*-sovereign` example in +`config.yaml`). The gateway, domain, and client keys are unchanged — you're +just swapping the backend from NVIDIA's cloud to your own silicon. diff --git a/infra/ai-gateway/docker-compose.yml b/infra/ai-gateway/docker-compose.yml new file mode 100644 index 0000000..a53234d --- /dev/null +++ b/infra/ai-gateway/docker-compose.yml @@ -0,0 +1,65 @@ +name: allerion-ai-gateway + +# api.allerion.io ──TLS──> Caddy ──> LiteLLM ──> NVIDIA hosted API +# +# LiteLLM is the OpenAI-compatible control plane: it authenticates YOUR +# clients with virtual keys, swaps in the real NVIDIA key server-side, and +# enforces rate limits / budgets. Caddy terminates TLS for api.allerion.io. +# Postgres backs LiteLLM's virtual keys, teams, and spend tracking. +# +# No GPU here — inference runs on NVIDIA's side. When you later stand up your +# own NIM/vLLM GPU boxes, add them to litellm/config.yaml and the gateway, +# clients, and keys stay exactly the same. + +services: + caddy: + image: caddy:2 + restart: unless-stopped + ports: + - "80:80" + - "443:443" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - litellm + + litellm: + image: ghcr.io/berriai/litellm:main-stable + restart: unless-stopped + command: ["--config", "/app/config.yaml", "--port", "4000"] + environment: + LITELLM_MASTER_KEY: ${LITELLM_MASTER_KEY:?set LITELLM_MASTER_KEY in .env} + LITELLM_SALT_KEY: ${LITELLM_SALT_KEY:?set LITELLM_SALT_KEY in .env} + NVIDIA_API_KEY: ${NVIDIA_API_KEY:?set NVIDIA_API_KEY in .env} + DATABASE_URL: postgresql://litellm:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/litellm + STORE_MODEL_IN_DB: "True" + volumes: + - ./litellm/config.yaml:/app/config.yaml:ro + # Only exposed inside the compose network; the internet reaches it via Caddy. + expose: + - "4000" + depends_on: + postgres: + condition: service_healthy + + postgres: + image: postgres:16 + restart: unless-stopped + environment: + POSTGRES_USER: litellm + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} + POSTGRES_DB: litellm + volumes: + - pg_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U litellm -d litellm"] + interval: 10s + timeout: 5s + retries: 5 + +volumes: + caddy_data: + caddy_config: + pg_data: diff --git a/infra/ai-gateway/litellm/config.yaml b/infra/ai-gateway/litellm/config.yaml new file mode 100644 index 0000000..fd2a6dd --- /dev/null +++ b/infra/ai-gateway/litellm/config.yaml @@ -0,0 +1,68 @@ +# LiteLLM proxy config — Allerion AI Gateway +# +# Every model below routes to NVIDIA's hosted, OpenAI-compatible endpoint +# (https://integrate.api.nvidia.com/v1) using your server-side NVIDIA_API_KEY. +# Clients NEVER see that key — they authenticate to this proxy with virtual +# keys you mint (see README). +# +# IMPORTANT: model strings after `nvidia_nim/` MUST match the exact model IDs +# in the NVIDIA catalog at https://build.nvidia.com/models. The list below is +# a representative starting set — verify/trim it against the catalog and your +# entitlement. `model_name` is the alias your clients call; the right-hand +# `model:` is what LiteLLM sends upstream. + +model_list: + - model_name: deepseek-r1 + litellm_params: + model: nvidia_nim/deepseek-ai/deepseek-r1 + api_key: os.environ/NVIDIA_API_KEY + + - model_name: llama-3.3-70b + litellm_params: + model: nvidia_nim/meta/llama-3.3-70b-instruct + api_key: os.environ/NVIDIA_API_KEY + + - model_name: nemotron-70b + litellm_params: + model: nvidia_nim/nvidia/llama-3.1-nemotron-70b-instruct + api_key: os.environ/NVIDIA_API_KEY + + - model_name: qwen-coder-32b + litellm_params: + model: nvidia_nim/qwen/qwen2.5-coder-32b-instruct + api_key: os.environ/NVIDIA_API_KEY + + - model_name: mistral-large + litellm_params: + model: nvidia_nim/mistralai/mistral-large-2-instruct + api_key: os.environ/NVIDIA_API_KEY + + # --- Verify the exact catalog IDs before enabling these --- + # - model_name: glm-4 + # litellm_params: + # model: nvidia_nim/zai/ + # api_key: os.environ/NVIDIA_API_KEY + # - model_name: kimi-k2 + # litellm_params: + # model: nvidia_nim/moonshotai/ + # api_key: os.environ/NVIDIA_API_KEY + + # --- Upgrade path: when you self-host on your own GPUs, add them here --- + # and clients/keys never change, just point at your NIM/vLLM box: + # - model_name: deepseek-r1-sovereign + # litellm_params: + # model: openai/deepseek-ai/deepseek-r1 + # api_base: http://gpu-node-1.internal:8000/v1 + # api_key: os.environ/SOVEREIGN_NODE_KEY + +general_settings: + master_key: os.environ/LITELLM_MASTER_KEY + database_url: os.environ/DATABASE_URL + +litellm_settings: + # Don't leak upstream provider errors verbatim to clients. + set_verbose: false + drop_params: true + # Sane default cap; override per virtual key. + max_budget: 100 + budget_duration: 30d From 6a4df2eb741236ae62409f7f0483482c52a9e308 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:33:59 +0000 Subject: [PATCH 02/14] Add Stripe metered billing to AI gateway Streams LiteLLM per-key usage into Stripe Billing Meters so gateway usage becomes invoices automatically. Markup is encoded in the Stripe price, so customers are billed underlying_cost x markup correctly across all models. - billing/billing_sync.py: idempotent usage->meter-event worker with a (startTime, request_id) watermark and a billing_unmapped audit table so no usage is silently dropped. - billing/setup_stripe.py: one-time meter + metered-price creation. - Opt-in compose profile 'billing'; base stack still runs without Stripe. - Docs for onboarding paying customers and reconciling unmapped usage. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- infra/ai-gateway/.env.example | 10 + infra/ai-gateway/README.md | 7 + infra/ai-gateway/billing/Dockerfile | 11 + infra/ai-gateway/billing/README.md | 74 ++++++ .../__pycache__/billing_sync.cpython-311.pyc | Bin 0 -> 11792 bytes .../__pycache__/setup_stripe.cpython-311.pyc | Bin 0 -> 2514 bytes infra/ai-gateway/billing/billing_sync.py | 223 ++++++++++++++++++ infra/ai-gateway/billing/requirements.txt | 2 + infra/ai-gateway/billing/setup_stripe.py | 45 ++++ infra/ai-gateway/docker-compose.yml | 15 ++ 10 files changed, 387 insertions(+) create mode 100644 infra/ai-gateway/billing/Dockerfile create mode 100644 infra/ai-gateway/billing/README.md create mode 100644 infra/ai-gateway/billing/__pycache__/billing_sync.cpython-311.pyc create mode 100644 infra/ai-gateway/billing/__pycache__/setup_stripe.cpython-311.pyc create mode 100644 infra/ai-gateway/billing/billing_sync.py create mode 100644 infra/ai-gateway/billing/requirements.txt create mode 100644 infra/ai-gateway/billing/setup_stripe.py diff --git a/infra/ai-gateway/.env.example b/infra/ai-gateway/.env.example index d8bd271..a35c44c 100644 --- a/infra/ai-gateway/.env.example +++ b/infra/ai-gateway/.env.example @@ -15,3 +15,13 @@ LITELLM_SALT_KEY=replace-with-strong-random # Postgres password for the bundled DB. # openssl rand -hex 24 POSTGRES_PASSWORD=replace-with-strong-random + +# --- Billing (optional; enable with: docker compose --profile billing up -d) --- +# Stripe secret key. Leave blank to run the gateway without billing. +STRIPE_API_KEY= +# Must match the meter created by billing/setup_stripe.py. +STRIPE_METER_EVENT_NAME=allerion_api_usage +# Seconds between usage-sync cycles. +BILLING_SYNC_INTERVAL=60 +# Customer pays underlying provider cost x this multiplier (used by setup_stripe.py). +MARKUP_MULTIPLIER=2.0 diff --git a/infra/ai-gateway/README.md b/infra/ai-gateway/README.md index 2dc1c71..cdb5c55 100644 --- a/infra/ai-gateway/README.md +++ b/infra/ai-gateway/README.md @@ -93,6 +93,13 @@ Edit `litellm/config.yaml`. The `model_name` is the alias clients call; the `model:` value must match an exact ID from . Verify IDs against the catalog and your entitlement before enabling them. +## Billing (charge customers) + +Optional metered billing via Stripe lives in [`billing/`](billing/). It maps each +virtual key to a Stripe customer and invoices `underlying_cost x markup` +automatically. Enable with `docker compose --profile billing up -d` — see +[`billing/README.md`](billing/README.md). + ## Hardening checklist - [ ] `.env` is git-ignored (it is) and never committed. diff --git a/infra/ai-gateway/billing/Dockerfile b/infra/ai-gateway/billing/Dockerfile new file mode 100644 index 0000000..8c5ea2c --- /dev/null +++ b/infra/ai-gateway/billing/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.12-slim + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY billing_sync.py setup_stripe.py ./ + +# Long-running worker; logs to stdout (-u for unbuffered). +CMD ["python", "-u", "billing_sync.py"] diff --git a/infra/ai-gateway/billing/README.md b/infra/ai-gateway/billing/README.md new file mode 100644 index 0000000..343af19 --- /dev/null +++ b/infra/ai-gateway/billing/README.md @@ -0,0 +1,74 @@ +# Metered billing (Stripe) + +Turns gateway usage into invoices. LiteLLM already records the underlying USD +cost of every request per virtual key; this worker maps each key to a Stripe +customer and reports usage to a **Stripe Billing Meter**. The **markup lives in +the Stripe price**, so a customer's bill = `underlying_cost x markup` — correct +across every model with no per-model price tables to maintain. + +``` +LiteLLM_SpendLogs ──> billing_sync ──> Stripe Meter Event ──> Price (markup) ──> Invoice + (usage) (this worker) (value = $ cost) (x multiplier) +``` + +## How a request becomes revenue +1. You create a **Stripe Customer** for a client and subscribe them to the metered price. +2. You **mint a LiteLLM key** for that client with `metadata.stripe_customer_id` set. +3. Every request that key makes is logged by LiteLLM with its USD cost. +4. `billing_sync` emits a meter event (`value` = that cost, `identifier` = request id) to Stripe. +5. Stripe aggregates and invoices automatically at the markup price. + +## One-time setup + +```bash +cd infra/ai-gateway/billing +pip install -r requirements.txt +STRIPE_API_KEY=sk_test_... MARKUP_MULTIPLIER=2.0 python setup_stripe.py +# prints the Meter id + Price id +``` + +Set `STRIPE_API_KEY` (and optionally `MARKUP_MULTIPLIER`) in `../.env`, then: + +```bash +cd .. +docker compose --profile billing up -d +docker compose logs -f billing +``` + +## Onboard a paying customer + +```bash +# 1. Stripe customer +stripe customers create --email="dev@acme.com" # -> cus_XXX +# 2. Subscribe them to the metered price from setup_stripe.py +stripe subscriptions create --customer=cus_XXX \ + -d "items[0][price]=price_XXX" +# 3. Mint a scoped LiteLLM key tied to that customer +curl https://api.allerion.io/key/generate \ + -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ + -H "Content-Type: application/json" \ + -d '{ + "models": ["deepseek-r1", "llama-3.3-70b"], + "rpm_limit": 120, + "metadata": {"stripe_customer_id": "cus_XXX"} + }' +``` + +Hand the returned `sk-...` key to the customer. Usage now bills automatically. + +## Design notes +- **Idempotent:** each meter event uses LiteLLM's `request_id` as its Stripe + identifier, so retries after a crash never double-bill. +- **No silent leakage:** usage from a key with no `stripe_customer_id` is written + to a `billing_unmapped` table for reconciliation instead of being dropped. +- **Watermark:** a `(startTime, request_id)` cursor in `billing_state` tracks + progress exactly, with no gaps or overlaps across restarts. +- **Schema drift:** all LiteLLM table/column names are isolated in + `fetch_rows()` in `billing_sync.py` — adjust there if your LiteLLM version + differs. + +## Reconcile unmapped usage +```sql +SELECT api_key, count(*), sum(spend) FROM billing_unmapped GROUP BY api_key; +``` +Attach a `stripe_customer_id` to those keys to bill them going forward. diff --git a/infra/ai-gateway/billing/__pycache__/billing_sync.cpython-311.pyc b/infra/ai-gateway/billing/__pycache__/billing_sync.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c06fb775f909acd2266453f11eb7057f985128be GIT binary patch literal 11792 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 Qte{I(bjkK#<8x@^KNr8v5dZ)H literal 0 HcmV?d00001 diff --git a/infra/ai-gateway/billing/billing_sync.py b/infra/ai-gateway/billing/billing_sync.py new file mode 100644 index 0000000..fa7208d --- /dev/null +++ b/infra/ai-gateway/billing/billing_sync.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +"""Allerion billing sync — turns LiteLLM usage into Stripe invoices. + +LiteLLM writes one row per request to LiteLLM_SpendLogs, including the +underlying USD `spend` (already model-aware) and the hashed api_key. We map +each key to a Stripe customer (via the key's metadata.stripe_customer_id), +then emit a Stripe Billing Meter event per request with value = underlying +cost in USD. The Stripe *price* encodes the markup, so the customer's invoice += underlying_cost x markup, correct across every model automatically. + +Idempotency: each meter event uses the LiteLLM request_id as its identifier, +so re-processing a row (after a crash/retry) is de-duplicated by Stripe. + +Schema note: LiteLLM's Postgres table/column names can drift between versions. +If a query errors, verify names against your deployed LiteLLM version — they +are all isolated in fetch_rows() below. +""" +import os +import sys +import json +import time +import signal +from datetime import datetime, timezone + +import psycopg2 +import psycopg2.extras +import stripe + +DATABASE_URL = os.environ["DATABASE_URL"] +STRIPE_API_KEY = os.environ.get("STRIPE_API_KEY", "").strip() +EVENT_NAME = os.environ.get("STRIPE_METER_EVENT_NAME", "allerion_api_usage") +INTERVAL = int(os.environ.get("BILLING_SYNC_INTERVAL", "60")) +BATCH = int(os.environ.get("BILLING_BATCH_SIZE", "500")) + +EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc) + +_running = True + + +def log(msg): + print(f"[billing] {datetime.now(timezone.utc).isoformat()} {msg}", flush=True) + + +def _stop(*_): + global _running + _running = False + log("shutdown signal received; finishing current cycle") + + +def bootstrap(conn): + with conn.cursor() as cur: + cur.execute( + """ + CREATE TABLE IF NOT EXISTS billing_state ( + k text PRIMARY KEY, + v text NOT NULL + ); + CREATE TABLE IF NOT EXISTS billing_unmapped ( + request_id text PRIMARY KEY, + api_key text, + model text, + spend numeric, + ts timestamptz, + recorded_at timestamptz DEFAULT now() + ); + """ + ) + conn.commit() + + +def get_watermark(conn): + with conn.cursor() as cur: + cur.execute("SELECT v FROM billing_state WHERE k = 'watermark'") + row = cur.fetchone() + if not row: + return EPOCH, "" + data = json.loads(row[0]) + return datetime.fromisoformat(data["ts"]), data.get("request_id", "") + + +def set_watermark(conn, ts, request_id): + payload = json.dumps({"ts": ts.isoformat(), "request_id": request_id}) + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO billing_state (k, v) VALUES ('watermark', %s) + ON CONFLICT (k) DO UPDATE SET v = EXCLUDED.v + """, + (payload,), + ) + conn.commit() + + +def fetch_rows(conn, ts, request_id, limit): + """Pull spend-log rows after the (ts, request_id) cursor, oldest first. + + The composite cursor avoids skipping rows that share a startTime. + """ + sql = """ + SELECT s.request_id, + s."startTime" AS ts, + s.spend AS spend, + s.model AS model, + s.api_key AS api_key, + v.metadata AS token_metadata + FROM "LiteLLM_SpendLogs" s + LEFT JOIN "LiteLLM_VerificationToken" v ON s.api_key = v.token + WHERE (s."startTime" > %(ts)s) + OR (s."startTime" = %(ts)s AND s.request_id > %(rid)s) + ORDER BY s."startTime" ASC, s.request_id ASC + LIMIT %(limit)s + """ + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(sql, {"ts": ts, "rid": request_id, "limit": limit}) + return cur.fetchall() + + +def resolve_customer(token_metadata): + if not token_metadata: + return None + meta = token_metadata + if isinstance(meta, str): + try: + meta = json.loads(meta) + except ValueError: + return None + if isinstance(meta, dict): + return meta.get("stripe_customer_id") + return None + + +def record_unmapped(conn, row): + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO billing_unmapped (request_id, api_key, model, spend, ts) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (request_id) DO NOTHING + """, + (row["request_id"], row["api_key"], row["model"], row["spend"], row["ts"]), + ) + conn.commit() + + +def emit(customer_id, value_usd, request_id): + stripe.billing.MeterEvent.create( + event_name=EVENT_NAME, + identifier=request_id, # Stripe de-dupes on this + payload={"stripe_customer_id": customer_id, "value": format(value_usd, "f")}, + ) + + +def process_batch(conn): + ts, rid = get_watermark(conn) + rows = fetch_rows(conn, ts, rid, BATCH) + if not rows: + return 0, 0 + + emitted = unmapped = 0 + for row in rows: + spend = float(row["spend"] or 0) + customer = resolve_customer(row["token_metadata"]) + + if spend > 0 and customer: + # Let a Stripe failure raise: we stop before advancing the + # watermark, so the row is retried (and de-duped) next cycle. + emit(customer, spend, row["request_id"]) + emitted += 1 + elif spend > 0 and not customer: + record_unmapped(conn, row) # no revenue silently lost + unmapped += 1 + + # Advance the cursor only after the row is safely handled. + set_watermark(conn, row["ts"], row["request_id"]) + + return emitted, unmapped + + +def main(): + if not STRIPE_API_KEY: + sys.exit("STRIPE_API_KEY is not set — billing sync requires a Stripe secret key.") + stripe.api_key = STRIPE_API_KEY + + signal.signal(signal.SIGTERM, _stop) + signal.signal(signal.SIGINT, _stop) + + log(f"starting; meter='{EVENT_NAME}', interval={INTERVAL}s, batch={BATCH}") + conn = psycopg2.connect(DATABASE_URL) + bootstrap(conn) + + while _running: + try: + emitted, unmapped = process_batch(conn) + if emitted or unmapped: + log(f"emitted={emitted} unmapped={unmapped}") + # Drain quickly when a full batch came back; otherwise idle. + if emitted + unmapped >= BATCH: + continue + except psycopg2.Error as e: + log(f"db error: {e}; reconnecting") + try: + conn.close() + except Exception: + pass + time.sleep(min(INTERVAL, 30)) + conn = psycopg2.connect(DATABASE_URL) + continue + except stripe.error.StripeError as e: + log(f"stripe error: {e}; will retry from watermark") + except Exception as e: # noqa: BLE001 - keep the worker alive + log(f"unexpected error: {e}; will retry") + + for _ in range(INTERVAL): + if not _running: + break + time.sleep(1) + + conn.close() + log("stopped") + + +if __name__ == "__main__": + main() diff --git a/infra/ai-gateway/billing/requirements.txt b/infra/ai-gateway/billing/requirements.txt new file mode 100644 index 0000000..82c838a --- /dev/null +++ b/infra/ai-gateway/billing/requirements.txt @@ -0,0 +1,2 @@ +stripe>=9.0.0 +psycopg2-binary>=2.9.9 diff --git a/infra/ai-gateway/billing/setup_stripe.py b/infra/ai-gateway/billing/setup_stripe.py new file mode 100644 index 0000000..b5faa3c --- /dev/null +++ b/infra/ai-gateway/billing/setup_stripe.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""One-time Stripe setup for Allerion metered billing. + +Creates a Billing Meter (sums per-customer usage) and a metered Price whose +per-unit amount encodes the markup. Run once: + + STRIPE_API_KEY=sk_test_... MARKUP_MULTIPLIER=2.0 python setup_stripe.py + +The billing_sync worker only needs STRIPE_METER_EVENT_NAME to match the meter +created here — Stripe handles aggregation -> price -> invoice from there. +""" +import os +import stripe + +stripe.api_key = os.environ["STRIPE_API_KEY"] +EVENT_NAME = os.environ.get("STRIPE_METER_EVENT_NAME", "allerion_api_usage") +MARKUP = float(os.environ.get("MARKUP_MULTIPLIER", "2.0")) + +# Unit of usage = $1.00 of underlying provider cost. The price charges +# (MARKUP x 100) cents per unit, so the customer pays underlying_cost x MARKUP. +unit_amount_decimal = format(MARKUP * 100, "f") + +meter = stripe.billing.Meter.create( + display_name="Allerion API usage", + event_name=EVENT_NAME, + default_aggregation={"formula": "sum"}, + customer_mapping={"type": "by_id", "event_payload_key": "stripe_customer_id"}, + value_settings={"event_payload_key": "value"}, +) +print(f"Meter created: {meter.id} (event_name={EVENT_NAME})") + +product = stripe.Product.create(name="Allerion API") +price = stripe.Price.create( + currency="usd", + unit_amount_decimal=unit_amount_decimal, + billing_scheme="per_unit", + recurring={"interval": "month", "usage_type": "metered", "meter": meter.id}, + product=product.id, +) +print(f"Price created: {price.id} ({MARKUP}x markup => {unit_amount_decimal} cents/unit)") +print() +print("Next:") +print(" 1. Create a Stripe Customer per client and subscribe them to this price.") +print(" 2. Mint a LiteLLM key with metadata.stripe_customer_id = that customer id.") +print(" 3. Start billing: docker compose --profile billing up -d") diff --git a/infra/ai-gateway/docker-compose.yml b/infra/ai-gateway/docker-compose.yml index a53234d..aa56aa3 100644 --- a/infra/ai-gateway/docker-compose.yml +++ b/infra/ai-gateway/docker-compose.yml @@ -59,6 +59,21 @@ services: timeout: 5s retries: 5 + # Metered billing: streams LiteLLM usage into Stripe. Opt-in — only starts + # with: docker compose --profile billing up -d + billing: + build: ./billing + restart: unless-stopped + profiles: ["billing"] + environment: + DATABASE_URL: postgresql://litellm:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/litellm + STRIPE_API_KEY: ${STRIPE_API_KEY:-} + STRIPE_METER_EVENT_NAME: ${STRIPE_METER_EVENT_NAME:-allerion_api_usage} + BILLING_SYNC_INTERVAL: ${BILLING_SYNC_INTERVAL:-60} + depends_on: + postgres: + condition: service_healthy + volumes: caddy_data: caddy_config: From fbd820e3d876e5aa9fa153ffbef5fc34180e119b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:34:07 +0000 Subject: [PATCH 03/14] Drop accidentally committed __pycache__ artifacts Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- infra/ai-gateway/.gitignore | 2 ++ .../__pycache__/billing_sync.cpython-311.pyc | Bin 11792 -> 0 bytes .../__pycache__/setup_stripe.cpython-311.pyc | Bin 2514 -> 0 bytes 3 files changed, 2 insertions(+) delete mode 100644 infra/ai-gateway/billing/__pycache__/billing_sync.cpython-311.pyc delete mode 100644 infra/ai-gateway/billing/__pycache__/setup_stripe.cpython-311.pyc diff --git a/infra/ai-gateway/.gitignore b/infra/ai-gateway/.gitignore index e49668b..8702e24 100644 --- a/infra/ai-gateway/.gitignore +++ b/infra/ai-gateway/.gitignore @@ -1,3 +1,5 @@ .env *.env !.env.example +__pycache__/ +*.pyc diff --git a/infra/ai-gateway/billing/__pycache__/billing_sync.cpython-311.pyc b/infra/ai-gateway/billing/__pycache__/billing_sync.cpython-311.pyc deleted file mode 100644 index c06fb775f909acd2266453f11eb7057f985128be..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11792 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 Qte{I(bjkK#<8x@^KNr8v5dZ)H From 5b4c8f171e3a036fb64597e9b5093fb4a540d7c7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 00:37:24 +0000 Subject: [PATCH 04/14] Bill sub-micro-dollar usage with 12-decimal precision Default 'f' formatting kept only 6 decimals, rounding tiny per-request costs to 0.000000 and silently dropping that usage. Use 12 decimals so cheap requests still meter. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- infra/ai-gateway/billing/billing_sync.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/infra/ai-gateway/billing/billing_sync.py b/infra/ai-gateway/billing/billing_sync.py index fa7208d..39ab972 100644 --- a/infra/ai-gateway/billing/billing_sync.py +++ b/infra/ai-gateway/billing/billing_sync.py @@ -143,10 +143,12 @@ def record_unmapped(conn, row): def emit(customer_id, value_usd, request_id): + # 12 decimals so sub-micro-dollar per-request costs aren't rounded to zero + # (default "f" formatting keeps only 6 and would silently drop tiny usage). stripe.billing.MeterEvent.create( event_name=EVENT_NAME, identifier=request_id, # Stripe de-dupes on this - payload={"stripe_customer_id": customer_id, "value": format(value_usd, "f")}, + payload={"stripe_customer_id": customer_id, "value": f"{value_usd:.12f}"}, ) From 7c4cada8297a132fd1400ed4fd8a86feb3835a68 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 02:42:50 +0000 Subject: [PATCH 05/14] Add Allerion Agent Market (agent-to-agent commerce app) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A functional A2A commerce simulation: AI merchant agents sell services to AI buyer agents via capability discovery and alternating-offer negotiation, settled through a ledger that takes a platform commission (the revenue model). Runs on the standard library with no API key (deterministic fulfilment) and upgrades to live fulfilment against any OpenAI-compatible endpoint (Allerion gateway, OpenRouter, NVIDIA, OpenAI) via env vars. Includes pytest suite (money conservation, settlement/fee, negotiation overlap, full-run invariants) — 7 passing. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- apps/agent-market/README.md | 74 ++++++++++ apps/agent-market/agentmarket/__init__.py | 11 ++ apps/agent-market/agentmarket/agents.py | 63 ++++++++ apps/agent-market/agentmarket/ledger.py | 74 ++++++++++ apps/agent-market/agentmarket/llm.py | 58 ++++++++ apps/agent-market/agentmarket/market.py | 102 +++++++++++++ apps/agent-market/agentmarket/marketplace.py | 78 ++++++++++ apps/agent-market/agentmarket/protocol.py | 79 ++++++++++ apps/agent-market/requirements.txt | 5 + apps/agent-market/run_demo.py | 148 +++++++++++++++++++ apps/agent-market/tests/test_market.py | 98 ++++++++++++ 11 files changed, 790 insertions(+) create mode 100644 apps/agent-market/README.md create mode 100644 apps/agent-market/agentmarket/__init__.py create mode 100644 apps/agent-market/agentmarket/agents.py create mode 100644 apps/agent-market/agentmarket/ledger.py create mode 100644 apps/agent-market/agentmarket/llm.py create mode 100644 apps/agent-market/agentmarket/market.py create mode 100644 apps/agent-market/agentmarket/marketplace.py create mode 100644 apps/agent-market/agentmarket/protocol.py create mode 100644 apps/agent-market/requirements.txt create mode 100644 apps/agent-market/run_demo.py create mode 100644 apps/agent-market/tests/test_market.py diff --git a/apps/agent-market/README.md b/apps/agent-market/README.md new file mode 100644 index 0000000..d881184 --- /dev/null +++ b/apps/agent-market/README.md @@ -0,0 +1,74 @@ +# Allerion Agent Market + +A functional **agent-to-agent (A2A) commerce** simulation: AI **merchant agents** +sell services (summarize, translate, enrich, copywrite) to AI **buyer agents**. +Buyers discover suppliers, **negotiate price** through alternating offers, +transact through a **settlement ledger**, and the platform takes a **commission +on every deal** — that's the revenue model. + +It runs with **zero dependencies and no API key** (deterministic fulfilment). +Point it at any **OpenAI-compatible endpoint** — the Allerion gateway, OpenRouter, +NVIDIA, OpenAI — to fulfil purchased work with a real model. + +## Run it + +```bash +cd apps/agent-market + +# Offline, deterministic — no key needed: +python run_demo.py + +# Live fulfilment via a real model (example: OpenRouter): +LLM_BASE_URL=https://openrouter.ai/api \ +LLM_API_KEY=sk-or-... \ +LLM_MODEL=openai/gpt-4o-mini \ +python run_demo.py + +# Live via the Allerion gateway (PR #2): +LLM_BASE_URL=https://api.allerion.io LLM_API_KEY=sk-... LLM_MODEL=deepseek-r1 python run_demo.py +``` + +## Tests + +```bash +pip install pytest +pytest -q +``` + +## How it works + +``` +buyer.need ─discover→ marketplace ─quotes→ negotiate (alternating offers) + │ deal price within [floor, value] + ▼ + ledger.settle ──fee──> platform wallet + │ net + ▼ + merchant wallet ; llm.fulfill() delivers work +``` + +| Module | Responsibility | +|--------|----------------| +| `protocol.py` | `Service`, `Need`, `Deal`, A2A `Message` types | +| `agents.py` | `MerchantAgent` (quoting, concession) and `BuyerAgent` (ranking, bidding) | +| `marketplace.py` | registry, capability discovery, the negotiation engine | +| `ledger.py` | wallets, settlement, platform commission, money-conservation invariant | +| `llm.py` | service fulfilment — real model or deterministic stub | +| `market.py` | the round loop + analytics (GMV, revenue, P&L) | +| `run_demo.py` | seeds an economy and prints transcript + ledger | + +## Economic model +- Each service has a `list_price` (opening ask) and a `floor_price` (walk-away). +- Each buyer need has a `value` (max willingness to pay). +- A deal closes only when the bargaining range overlaps (`floor ≤ value`), at a + price in between — so margins and outcomes vary by agent strategy. +- **Money is conserved**: every settlement moves funds buyer → merchant + a + commission to the platform. `summary()["money_conserved"]` must stay `0.00` + (opening balances are funded from a mint account that nets out). + +## Going further +- Swap deterministic agent policies for **LLM-driven negotiation** (have each + agent reason about its next offer via `llm.fulfill`). +- Wire settlement to the **Stripe metered billing** in `infra/ai-gateway/billing` + so agent spend becomes real invoices. +- Expose the marketplace over HTTP so external agents can register and trade. diff --git a/apps/agent-market/agentmarket/__init__.py b/apps/agent-market/agentmarket/__init__.py new file mode 100644 index 0000000..4031323 --- /dev/null +++ b/apps/agent-market/agentmarket/__init__.py @@ -0,0 +1,11 @@ +"""Allerion Agent Market — agent-to-agent (A2A) commerce. + +AI merchant agents sell services to AI buyer agents. Buyers discover +suppliers, negotiate price, transact through a settlement ledger, and the +platform takes a commission on every deal. The whole simulation runs with +zero external dependencies; service fulfilment optionally calls a real +OpenAI-compatible endpoint (e.g. the Allerion gateway) when configured. +""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/apps/agent-market/agentmarket/agents.py b/apps/agent-market/agentmarket/agents.py new file mode 100644 index 0000000..2976f64 --- /dev/null +++ b/apps/agent-market/agentmarket/agents.py @@ -0,0 +1,63 @@ +"""The agents: merchants that sell services and buyers that procure them.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional + +from .protocol import Need, Service + + +@dataclass +class MerchantAgent: + id: str + name: str + services: List[Service] = field(default_factory=list) + concession: float = 0.4 # how fast it moves from ask toward floor (0..1) + sales: int = 0 + + def offer(self, service: Service) -> "MerchantAgent": + self.services.append(service) + return self + + def quote(self, service: Service) -> float: + return service.list_price + + def counter(self, service: Service, current_ask: float, buyer_bid: float) -> float: + """Concede toward the buyer, but never below the service floor.""" + target = max(service.floor_price, current_ask - (current_ask - service.floor_price) * self.concession) + # If the buyer's bid already clears the floor, meet in the middle. + if buyer_bid >= service.floor_price: + return round(max(service.floor_price, min(target, (target + buyer_bid) / 2)), 2) + return round(target, 2) + + +@dataclass +class BuyerAgent: + id: str + name: str + needs: List[Need] = field(default_factory=list) + quality_weight: float = 0.5 # how much it trades price for quality + concession: float = 0.4 + purchases: int = 0 + + def score(self, need: Need, service: Service) -> float: + """Expected utility of a supplier before negotiating: value adjusted + for quality, minus the opening ask. Higher is better.""" + perceived_value = need.value * (0.5 + self.quality_weight * service.quality) + return perceived_value - service.list_price + + def rank(self, need: Need, services: List[Service]) -> List[Service]: + return sorted(services, key=lambda s: self.score(need, s), reverse=True) + + def opening_bid(self, need: Need, service: Service) -> float: + # Lowball but never above what the need is worth. + return round(min(need.value, service.list_price * 0.6), 2) + + def raise_bid(self, need: Need, current_bid: float) -> float: + return round(min(need.value, current_bid + (need.value - current_bid) * self.concession), 2) + + def accepts(self, need: Need, price: float) -> bool: + return price <= need.value + + def next_need(self) -> Optional[Need]: + return self.needs.pop(0) if self.needs else None diff --git a/apps/agent-market/agentmarket/ledger.py b/apps/agent-market/agentmarket/ledger.py new file mode 100644 index 0000000..d955d21 --- /dev/null +++ b/apps/agent-market/agentmarket/ledger.py @@ -0,0 +1,74 @@ +"""Settlement ledger: wallets, transactions, and the platform's cut. + +Money is conserved: every settlement moves funds from a buyer to a merchant +and a commission to the platform (house) wallet — no money is created or +destroyed. `total_money()` is the invariant the tests assert on. +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List + +from .protocol import Deal + + +class InsufficientFunds(Exception): + pass + + +@dataclass +class Entry: + round: int + debit: str # who paid + credit: str # who received + amount: float + memo: str + + +@dataclass +class Ledger: + house_id: str = "allerion" + fee_rate: float = 0.10 # platform commission on every deal + balances: Dict[str, float] = field(default_factory=dict) + entries: List[Entry] = field(default_factory=list) + + def open_account(self, agent_id: str, opening_balance: float = 0.0) -> None: + self.balances.setdefault(agent_id, 0.0) + if opening_balance: + # Opening balances are funded from a mint account so the live + # economy still conserves money after seeding. + self.balances[agent_id] += opening_balance + self.balances.setdefault("__mint__", 0.0) + self.balances["__mint__"] -= opening_balance + + def balance(self, agent_id: str) -> float: + return self.balances.get(agent_id, 0.0) + + def settle(self, deal: Deal) -> Deal: + """Move `deal.price` buyer→merchant, skimming `fee_rate` to the house.""" + if self.balance(deal.buyer_id) < deal.price: + raise InsufficientFunds( + f"{deal.buyer_id} has ${self.balance(deal.buyer_id):,.2f}, " + f"needs ${deal.price:,.2f}" + ) + fee = round(deal.price * self.fee_rate, 2) + net = round(deal.price - fee, 2) + + self.balances[deal.buyer_id] -= deal.price + self.balances.setdefault(deal.merchant_id, 0.0) + self.balances[deal.merchant_id] += net + self.balances.setdefault(self.house_id, 0.0) + self.balances[self.house_id] += fee + + self.entries.append(Entry(deal.round, deal.buyer_id, deal.merchant_id, net, + f"{deal.capability} ({deal.service_id})")) + self.entries.append(Entry(deal.round, deal.buyer_id, self.house_id, fee, + f"commission {self.fee_rate:.0%}")) + deal.fee = fee + return deal + + def total_money(self) -> float: + return round(sum(self.balances.values()), 2) + + def platform_revenue(self) -> float: + return round(self.balance(self.house_id), 2) diff --git a/apps/agent-market/agentmarket/llm.py b/apps/agent-market/agentmarket/llm.py new file mode 100644 index 0000000..e42ce10 --- /dev/null +++ b/apps/agent-market/agentmarket/llm.py @@ -0,0 +1,58 @@ +"""Service fulfilment. + +If LLM_BASE_URL and LLM_API_KEY are set, fulfilment calls a real +OpenAI-compatible endpoint (point it at the Allerion gateway, OpenRouter, +NVIDIA, OpenAI, etc.). Otherwise it returns a deterministic stub so the whole +market runs offline with no keys. +""" +from __future__ import annotations + +import os +import textwrap + +BASE_URL = os.environ.get("LLM_BASE_URL", "").rstrip("/") +API_KEY = os.environ.get("LLM_API_KEY", "") +MODEL = os.environ.get("LLM_MODEL", "deepseek-r1") + + +def live() -> bool: + return bool(BASE_URL and API_KEY) + + +def _stub(capability: str, prompt: str) -> str: + body = prompt or f"(no prompt supplied for {capability})" + return textwrap.shorten( + f"[stub:{capability}] delivered work for → {body}", width=160, placeholder=" …" + ) + + +def fulfill(capability: str, prompt: str) -> str: + """Return the work product for one purchased service unit.""" + if not live(): + return _stub(capability, prompt) + + # Use the standard library so live mode needs no extra dependency. + import json + import urllib.request + + system = f"You are a specialist agent providing the '{capability}' service. Be concise." + payload = json.dumps({ + "model": MODEL, + "messages": [ + {"role": "system", "content": system}, + {"role": "user", "content": prompt or capability}, + ], + "max_tokens": 120, + }).encode() + req = urllib.request.Request( + f"{BASE_URL}/v1/chat/completions", + data=payload, + headers={"Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read()) + return data["choices"][0]["message"]["content"].strip() + except Exception as e: # noqa: BLE001 — never let fulfilment crash the market + return f"[fulfilment error via {MODEL}: {e}]" diff --git a/apps/agent-market/agentmarket/market.py b/apps/agent-market/agentmarket/market.py new file mode 100644 index 0000000..f6a72ae --- /dev/null +++ b/apps/agent-market/agentmarket/market.py @@ -0,0 +1,102 @@ +"""The market loop: runs rounds where buyers procure from merchants.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List + +from . import llm +from .agents import BuyerAgent, MerchantAgent +from .ledger import InsufficientFunds, Ledger +from .marketplace import Marketplace +from .protocol import Deal, Message + + +@dataclass +class RoundReport: + round: int + deals: List[Deal] = field(default_factory=list) + transcript: List[Message] = field(default_factory=list) + no_deals: int = 0 + + +@dataclass +class Market: + marketplace: Marketplace + ledger: Ledger + buyers: Dict[str, BuyerAgent] = field(default_factory=dict) + reports: List[RoundReport] = field(default_factory=list) + + def add_buyer(self, buyer: BuyerAgent, opening_balance: float) -> None: + self.buyers[buyer.id] = buyer + self.ledger.open_account(buyer.id, opening_balance) + + def _ensure_accounts(self) -> None: + for mid in self.marketplace.merchants: + self.ledger.open_account(mid) + self.ledger.open_account(self.ledger.house_id) + + def run_round(self, round_no: int, fulfill: bool = True) -> RoundReport: + self._ensure_accounts() + self.marketplace.reset_round() + report = RoundReport(round=round_no) + + for buyer in self.buyers.values(): + need = buyer.next_need() + if need is None: + continue + + suppliers = self.marketplace.discover(need.capability) + if not suppliers: + report.no_deals += 1 + continue + + closed = False + for service in buyer.rank(need, suppliers): + if self.marketplace._remaining_capacity.get(service.id, 0) <= 0: + continue + price, n, transcript = self.marketplace.negotiate(buyer, need, service) + report.transcript.extend(transcript) + if price is None: + continue + if self.ledger.balance(buyer.id) < price: + continue # can't afford; try next supplier + + deal = Deal(round_no, buyer.id, service.merchant_id, service.id, + need.capability, price, n) + try: + self.ledger.settle(deal) + except InsufficientFunds: + continue + + self.marketplace.consume_capacity(service) + self.marketplace.merchants[service.merchant_id].sales += 1 + buyer.purchases += 1 + if fulfill: + deal.delivered = llm.fulfill(need.capability, need.prompt) + report.deals.append(deal) + closed = True + break + + if not closed: + report.no_deals += 1 + + self.reports.append(report) + return report + + def run(self, rounds: int, fulfill: bool = True) -> List[RoundReport]: + return [self.run_round(r, fulfill=fulfill) for r in range(1, rounds + 1)] + + # --- analytics --------------------------------------------------------- + def all_deals(self) -> List[Deal]: + return [d for rep in self.reports for d in rep.deals] + + def summary(self) -> dict: + deals = self.all_deals() + gmv = round(sum(d.price for d in deals), 2) + return { + "deals": len(deals), + "gmv": gmv, + "platform_revenue": self.ledger.platform_revenue(), + "avg_price": round(gmv / len(deals), 2) if deals else 0.0, + "money_conserved": self.ledger.total_money(), + } diff --git a/apps/agent-market/agentmarket/marketplace.py b/apps/agent-market/agentmarket/marketplace.py new file mode 100644 index 0000000..b09dcda --- /dev/null +++ b/apps/agent-market/agentmarket/marketplace.py @@ -0,0 +1,78 @@ +"""The marketplace: a registry, discovery, and the negotiation engine.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple + +from .agents import BuyerAgent, MerchantAgent +from .protocol import Deal, Message, MessageType, Need, Service + +MAX_NEGOTIATION_ROUNDS = 6 + + +@dataclass +class Marketplace: + merchants: Dict[str, MerchantAgent] = field(default_factory=dict) + services: List[Service] = field(default_factory=list) + _remaining_capacity: Dict[str, int] = field(default_factory=dict) + + def register(self, merchant: MerchantAgent) -> None: + self.merchants[merchant.id] = merchant + for svc in merchant.services: + self.services.append(svc) + + def reset_round(self) -> None: + self._remaining_capacity = {s.id: s.capacity_per_round for s in self.services} + + def discover(self, capability: str) -> List[Service]: + """Suppliers for a capability that still have capacity this round.""" + return [ + s for s in self.services + if s.capability == capability and self._remaining_capacity.get(s.id, 0) > 0 + ] + + def negotiate( + self, buyer: BuyerAgent, need: Need, service: Service + ) -> Tuple[Optional[float], int, List[Message]]: + """Alternating-offer bargaining. Returns (agreed_price | None, rounds, transcript).""" + merchant = self.merchants[service.merchant_id] + transcript: List[Message] = [ + Message(MessageType.DISCOVER, buyer.id, service.merchant_id, need.capability) + ] + + ask = merchant.quote(service) + transcript.append(Message(MessageType.QUOTE, service.merchant_id, buyer.id, need.capability, ask)) + + # Impossible deal: merchant's floor is above what the need is worth. + if service.floor_price > need.value: + transcript.append(Message(MessageType.REJECT, buyer.id, service.merchant_id, need.capability)) + return None, 0, transcript + + bid = buyer.opening_bid(need, service) + transcript.append(Message(MessageType.OFFER, buyer.id, service.merchant_id, need.capability, bid)) + + for r in range(1, MAX_NEGOTIATION_ROUNDS + 1): + if bid >= ask: # buyer's bid meets the ask → done at the ask + transcript.append(Message(MessageType.ACCEPT, buyer.id, service.merchant_id, need.capability, ask)) + return round(ask, 2), r, transcript + + ask = merchant.counter(service, ask, bid) + transcript.append(Message(MessageType.COUNTER, service.merchant_id, buyer.id, need.capability, ask)) + + if bid >= ask and buyer.accepts(need, ask): + transcript.append(Message(MessageType.ACCEPT, buyer.id, service.merchant_id, need.capability, ask)) + return round(ask, 2), r, transcript + + bid = buyer.raise_bid(need, bid) + transcript.append(Message(MessageType.OFFER, buyer.id, service.merchant_id, need.capability, bid)) + + if bid >= ask and buyer.accepts(need, ask): + price = round((ask + bid) / 2, 2) + transcript.append(Message(MessageType.ACCEPT, buyer.id, service.merchant_id, need.capability, price)) + return price, r, transcript + + transcript.append(Message(MessageType.REJECT, buyer.id, service.merchant_id, need.capability)) + return None, MAX_NEGOTIATION_ROUNDS, transcript + + def consume_capacity(self, service: Service) -> None: + self._remaining_capacity[service.id] -= 1 diff --git a/apps/agent-market/agentmarket/protocol.py b/apps/agent-market/agentmarket/protocol.py new file mode 100644 index 0000000..e70a1c7 --- /dev/null +++ b/apps/agent-market/agentmarket/protocol.py @@ -0,0 +1,79 @@ +"""A2A protocol primitives: the things agents offer, want, and exchange.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Optional + + +class MessageType(str, Enum): + DISCOVER = "discover" + QUOTE = "quote" + OFFER = "offer" + COUNTER = "counter" + ACCEPT = "accept" + REJECT = "reject" + DELIVER = "deliver" + SETTLE = "settle" + + +@dataclass(frozen=True) +class Service: + """A capability a merchant agent sells, with its pricing envelope.""" + + id: str + name: str + capability: str # the tag buyers search on, e.g. "summarize" + merchant_id: str + list_price: float # opening ask + floor_price: float # walk-away minimum + quality: float # 0..1, drives buyer's perceived value + capacity_per_round: int # how many deals it can fulfil per round + + def __post_init__(self) -> None: + if self.floor_price > self.list_price: + raise ValueError(f"{self.id}: floor_price exceeds list_price") + if not 0.0 <= self.quality <= 1.0: + raise ValueError(f"{self.id}: quality must be in [0,1]") + + +@dataclass(frozen=True) +class Need: + """Something a buyer agent wants done, with its max willingness to pay.""" + + id: str + buyer_id: str + capability: str + value: float # the most this buyer will pay for one unit + prompt: str = "" # passed to fulfilment + + +@dataclass +class Deal: + """A closed transaction between a buyer and a merchant.""" + + round: int + buyer_id: str + merchant_id: str + service_id: str + capability: str + price: float + rounds_negotiated: int + delivered: Optional[str] = None + fee: float = 0.0 + + +@dataclass +class Message: + """An A2A message exchanged during negotiation (kept for the transcript).""" + + mtype: MessageType + sender: str + recipient: str + capability: str + price: Optional[float] = None + meta: dict = field(default_factory=dict) + + def render(self) -> str: + amount = f" ${self.price:,.2f}" if self.price is not None else "" + return f"{self.sender:>12} →{self.recipient:>12} {self.mtype.value.upper():<8}{amount} [{self.capability}]" diff --git a/apps/agent-market/requirements.txt b/apps/agent-market/requirements.txt new file mode 100644 index 0000000..82e62a0 --- /dev/null +++ b/apps/agent-market/requirements.txt @@ -0,0 +1,5 @@ +# The app — offline AND live fulfilment — runs on the Python standard library +# alone. No runtime dependencies. +# +# Dev only: +pytest>=8.0 diff --git a/apps/agent-market/run_demo.py b/apps/agent-market/run_demo.py new file mode 100644 index 0000000..524dfd3 --- /dev/null +++ b/apps/agent-market/run_demo.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +"""Allerion Agent Market — runnable demo. + +Seeds a small economy of merchant and buyer agents, runs several trading +rounds, and prints the negotiation transcript, the settlement ledger, and the +platform's take. Runs with no API key (deterministic fulfilment); set +LLM_BASE_URL + LLM_API_KEY to fulfil purchased work with a real model. + + python run_demo.py # offline, deterministic + LLM_BASE_URL=https://api.allerion.io LLM_API_KEY=sk-... python run_demo.py +""" +from __future__ import annotations + +import random + +from agentmarket import llm +from agentmarket.agents import BuyerAgent, MerchantAgent +from agentmarket.ledger import Ledger +from agentmarket.market import Market +from agentmarket.marketplace import Marketplace +from agentmarket.protocol import Need, Service + +ROUNDS = 5 +SEED = 7 + + +def build_marketplace() -> Marketplace: + mp = Marketplace() + scribe = MerchantAgent("scribe", "Scribe.ai", concession=0.35) + scribe.offer(Service("scribe-sum", "Premium Summaries", "summarize", "scribe", 10, 6, 0.90, 3)) + scribe.offer(Service("scribe-copy", "Brand Copy", "copywrite", "scribe", 14, 9, 0.85, 2)) + + lingua = MerchantAgent("lingua", "Lingua.ai", concession=0.5) + lingua.offer(Service("lingua-tr", "Fast Translate", "translate", "lingua", 8, 5, 0.80, 4)) + lingua.offer(Service("lingua-sum", "Budget Summaries", "summarize", "lingua", 7, 4, 0.60, 3)) + + forge = MerchantAgent("forge", "DataForge", concession=0.3) + forge.offer(Service("forge-en", "Deep Enrichment", "enrich", "forge", 20, 12, 0.95, 2)) + forge.offer(Service("forge-tr", "Technical Translate", "translate", "forge", 9, 6, 0.70, 2)) + + quill = MerchantAgent("quill", "Quill", concession=0.45) + quill.offer(Service("quill-copy", "Value Copy", "copywrite", "quill", 11, 7, 0.70, 3)) + quill.offer(Service("quill-sum", "Standard Summaries", "summarize", "quill", 9, 5, 0.75, 2)) + + for m in (scribe, lingua, forge, quill): + mp.register(m) + return mp + + +def needs_for(buyer_id: str, templates, rounds: int): + out = [] + for r in range(rounds): + cap, value, prompt = templates[r % len(templates)] + out.append(Need(f"{buyer_id}-n{r}", buyer_id, cap, value, prompt)) + return out + + +def build_market() -> Market: + mp = build_marketplace() + ledger = Ledger(house_id="allerion", fee_rate=0.10) + market = Market(marketplace=mp, ledger=ledger) + + atlas = BuyerAgent("atlas", "Atlas Corp", quality_weight=0.7, concession=0.4) + atlas.needs = needs_for("atlas", [ + ("enrich", 25, "enrich 500 B2B leads with firmographics"), + ("summarize", 12, "summarize the Q2 earnings call"), + ("copywrite", 16, "write a launch announcement for our API"), + ], ROUNDS) + + nimbus = BuyerAgent("nimbus", "Nimbus Labs", quality_weight=0.3, concession=0.45) + nimbus.needs = needs_for("nimbus", [ + ("summarize", 10, "tl;dr this support thread"), + ("translate", 9, "translate docs to Spanish"), + ], ROUNDS) + + orbit = BuyerAgent("orbit", "Orbit Retail", quality_weight=0.5, concession=0.4) + orbit.needs = needs_for("orbit", [ + ("translate", 10, "translate product listings to German"), + ("copywrite", 15, "write 20 product descriptions"), + ("enrich", 22, "enrich our customer table with industry codes"), + ], ROUNDS) + + market.add_buyer(atlas, opening_balance=200.0) + market.add_buyer(nimbus, opening_balance=120.0) + market.add_buyer(orbit, opening_balance=150.0) + return market + + +def hr(char="─", n=72): + print(char * n) + + +def main() -> None: + random.seed(SEED) + market = build_market() + + print() + hr("═") + print(" ALLERION AGENT MARKET — agent-to-agent commerce") + print(f" fulfilment: {'LIVE via ' + llm.MODEL if llm.live() else 'deterministic stub (no API key)'}") + print(f" platform commission: {market.ledger.fee_rate:.0%} rounds: {ROUNDS}") + hr("═") + + reports = market.run(ROUNDS, fulfill=True) + + print("\n Sample negotiation transcript (round 1):") + hr() + for msg in reports[0].transcript[:14]: + print(" " + msg.render()) + hr() + + print("\n Per-round activity:") + for rep in reports: + gmv = sum(d.price for d in rep.deals) + print(f" round {rep.round}: {len(rep.deals)} deals · ${gmv:6.2f} GMV · {rep.no_deals} unmatched") + + print("\n Closed deals:") + hr() + for d in market.all_deals(): + print(f" r{d.round} {d.buyer_id:>7} → {d.merchant_id:<7} {d.capability:<10} " + f"${d.price:6.2f} (fee ${d.fee:.2f}, {d.rounds_negotiated} rounds)") + hr() + + print("\n Final balances (wallets):") + for aid, bal in sorted(market.ledger.balances.items(), key=lambda kv: -kv[1]): + if aid == "__mint__": + continue + tag = " ← platform" if aid == market.ledger.house_id else "" + print(f" {aid:>10}: ${bal:8.2f}{tag}") + + s = market.summary() + print("\n Summary:") + hr() + print(f" deals closed : {s['deals']}") + print(f" GMV : ${s['gmv']:.2f}") + print(f" platform revenue : ${s['platform_revenue']:.2f} ({market.ledger.fee_rate:.0%} of GMV)") + print(f" avg deal price : ${s['avg_price']:.2f}") + print(f" money conserved : ${s['money_conserved']:.2f} (sum of all wallets incl. mint == 0.00)") + hr() + + if market.all_deals() and market.all_deals()[0].delivered: + print("\n Sample delivered work product:") + print(" " + market.all_deals()[0].delivered) + print() + + +if __name__ == "__main__": + main() diff --git a/apps/agent-market/tests/test_market.py b/apps/agent-market/tests/test_market.py new file mode 100644 index 0000000..a3579a8 --- /dev/null +++ b/apps/agent-market/tests/test_market.py @@ -0,0 +1,98 @@ +"""Tests for the agent market: money conservation, settlement, negotiation.""" +import os +import sys + +import pytest + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from agentmarket.agents import BuyerAgent, MerchantAgent +from agentmarket.ledger import InsufficientFunds, Ledger +from agentmarket.marketplace import Marketplace +from agentmarket.market import Market +from agentmarket.protocol import Deal, Need, Service + + +def make_service(**kw): + base = dict(id="s1", name="S", capability="summarize", merchant_id="m1", + list_price=10, floor_price=6, quality=0.8, capacity_per_round=2) + base.update(kw) + return Service(**base) + + +def test_service_rejects_bad_envelope(): + with pytest.raises(ValueError): + make_service(list_price=5, floor_price=9) + with pytest.raises(ValueError): + make_service(quality=1.5) + + +def test_ledger_conserves_money(): + led = Ledger(fee_rate=0.1) + led.open_account("buyer", 100) + led.open_account("m1") + assert led.total_money() == 0.0 # mint offsets opening balances + led.settle(Deal(1, "buyer", "m1", "s1", "summarize", 10, 1)) + assert led.total_money() == 0.0 # still conserved after a trade + + +def test_settlement_splits_fee(): + led = Ledger(fee_rate=0.1) + led.open_account("buyer", 100) + led.open_account("m1") + led.settle(Deal(1, "buyer", "m1", "s1", "summarize", 10, 1)) + assert led.balance("buyer") == 90.0 + assert led.balance("m1") == 9.0 # 10 - 10% fee + assert led.platform_revenue() == 1.0 + + +def test_insufficient_funds_blocks_settlement(): + led = Ledger() + led.open_account("buyer", 5) + led.open_account("m1") + with pytest.raises(InsufficientFunds): + led.settle(Deal(1, "buyer", "m1", "s1", "summarize", 10, 1)) + + +def test_negotiation_closes_when_overlap_exists(): + mp = Marketplace() + m = MerchantAgent("m1", "M").offer(make_service(list_price=10, floor_price=6)) + mp.register(m) + mp.reset_round() + buyer = BuyerAgent("b1", "B") + need = Need("n1", "b1", "summarize", value=12) + price, rounds, transcript = mp.negotiate(buyer, need, mp.services[0]) + assert price is not None + assert 6 <= price <= 12 + assert rounds >= 1 + + +def test_negotiation_fails_when_floor_above_value(): + mp = Marketplace() + m = MerchantAgent("m1", "M").offer(make_service(list_price=20, floor_price=15)) + mp.register(m) + mp.reset_round() + buyer = BuyerAgent("b1", "B") + need = Need("n1", "b1", "summarize", value=10) # worth less than the floor + price, _, _ = mp.negotiate(buyer, need, mp.services[0]) + assert price is None + + +def test_full_market_run_conserves_and_trades(): + mp = Marketplace() + seller = MerchantAgent("m1", "M", concession=0.4) + seller.offer(make_service(capacity_per_round=5)) + mp.register(seller) + market = Market(marketplace=mp, ledger=Ledger(fee_rate=0.1)) + buyer = BuyerAgent("b1", "B") + buyer.needs = [Need(f"n{i}", "b1", "summarize", 12) for i in range(3)] + market.add_buyer(buyer, opening_balance=100) + + market.run(3, fulfill=True) + + summary = market.summary() + assert summary["deals"] >= 1 + assert summary["money_conserved"] == 0.0 + assert summary["platform_revenue"] > 0 + # GMV must equal what left the buyer's wallet. + assert round(100 - market.ledger.balance("b1"), 2) == summary["gmv"] From db875a5c47fb1768da8b6f77b9570c5513bd9f2c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 03:55:14 +0000 Subject: [PATCH 06/14] Add Allerion platform: marketing site + embedded CRM Zero-dependency stdlib app (http.server + sqlite3) serving the Allerion marketing landing page and an embedded CRM over one SQLite store. Access- request form captures leads; CRM dashboard tracks them through a new->contacted->qualified->won/lost pipeline with a JSON API. Verified end-to-end: landing render, lead capture (form + JSON), status updates, pipeline KPIs. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- apps/platform/.gitignore | 2 + apps/platform/README.md | 55 +++++++ apps/platform/server.py | 343 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 400 insertions(+) create mode 100644 apps/platform/.gitignore create mode 100644 apps/platform/README.md create mode 100644 apps/platform/server.py diff --git a/apps/platform/.gitignore b/apps/platform/.gitignore new file mode 100644 index 0000000..6a1d4a7 --- /dev/null +++ b/apps/platform/.gitignore @@ -0,0 +1,2 @@ +crm.db +*.db diff --git a/apps/platform/README.md b/apps/platform/README.md new file mode 100644 index 0000000..161d1f9 --- /dev/null +++ b/apps/platform/README.md @@ -0,0 +1,55 @@ +# Allerion Platform — marketing site + embedded CRM + +A single, zero-dependency app (Python standard library only) that serves the +Allerion **marketing website** and an **embedded CRM** over one SQLite store. +Access requests on the landing page become leads; the CRM tracks them through a +pipeline. + +## Run + +```bash +cd apps/platform +python3 server.py # http://127.0.0.1:8099 +python3 server.py --port 9000 --host 0.0.0.0 +``` + +No `pip install` — it uses only `http.server` + `sqlite3`. + +## Routes + +| Method | Path | Purpose | +|--------|------|---------| +| GET | `/` | Marketing landing page (capabilities, pricing, access form) | +| POST | `/api/leads` | Create a lead (HTML form post → redirects to `/crm`; JSON → returns the lead) | +| GET | `/crm` | CRM dashboard: pipeline KPIs + lead table with inline status updates | +| GET | `/api/leads` | List leads as JSON | +| POST | `/api/leads//status` | Advance a lead's pipeline status (`new → contacted → qualified → won/lost`) | +| GET | `/healthz` | Health check | + +## Examples + +```bash +# Capture a lead via the JSON API +curl -X POST http://127.0.0.1:8099/api/leads -H 'Content-Type: application/json' \ + -d '{"name":"Jane","email":"jane@acme.co","company":"Acme","interest":"AI Gateway"}' + +# Move it through the pipeline +curl -X POST http://127.0.0.1:8099/api/leads/1/status \ + -H 'Content-Type: application/json' -d '{"status":"qualified"}' + +# Read the CRM +curl http://127.0.0.1:8099/api/leads +``` + +## Data + +SQLite at `apps/platform/crm.db` (git-ignored), overridable via `ALLERION_CRM_DB`. +The `leads` table is created automatically on first run. + +## How it fits the platform + +This is the customer-facing front of the Allerion stack: the **landing page** +sells the AI Gateway / Agent Orchestration / Sovereign AI offerings, and the +**embedded CRM** captures and qualifies the demand. It runs standalone today; +the lead pipeline is the natural place to later connect Stripe billing +(`infra/ai-gateway/billing/`) when a lead converts to a paying gateway tenant. diff --git a/apps/platform/server.py b/apps/platform/server.py new file mode 100644 index 0000000..15820fa --- /dev/null +++ b/apps/platform/server.py @@ -0,0 +1,343 @@ +#!/usr/bin/env python3 +"""Allerion platform — marketing site + embedded CRM in one app. + +Zero dependencies (Python standard library only): an HTTP server that serves +the Allerion marketing site, captures access requests as CRM leads in SQLite, +and exposes a lightweight CRM dashboard + JSON API over the same data. + + python3 server.py # http://127.0.0.1:8099 + python3 server.py --port 9000 + +Routes + GET / marketing landing page (lead-capture form) + POST /api/leads create a lead (form post or JSON) + GET /crm CRM dashboard (pipeline + lead list) + GET /api/leads list leads as JSON + POST /api/leads//status advance a lead's pipeline status + GET /healthz health check +""" +from __future__ import annotations + +import argparse +import html +import json +import os +import sqlite3 +import urllib.parse +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +DB_PATH = os.environ.get("ALLERION_CRM_DB", os.path.join(os.path.dirname(__file__), "crm.db")) +PIPELINE = ["new", "contacted", "qualified", "won", "lost"] + + +# --------------------------------------------------------------------------- db +def db() -> sqlite3.Connection: + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db() -> None: + with db() as conn: + conn.execute( + """ + CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + email TEXT NOT NULL, + company TEXT, + interest TEXT, + message TEXT, + status TEXT NOT NULL DEFAULT 'new', + created_at TEXT NOT NULL + ) + """ + ) + + +def create_lead(data: dict) -> dict: + name = (data.get("name") or "").strip() + email = (data.get("email") or "").strip() + if not name or not email: + raise ValueError("name and email are required") + row = { + "name": name, + "email": email, + "company": (data.get("company") or "").strip(), + "interest": (data.get("interest") or "").strip(), + "message": (data.get("message") or "").strip(), + "status": "new", + "created_at": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + with db() as conn: + cur = conn.execute( + "INSERT INTO leads (name,email,company,interest,message,status,created_at) " + "VALUES (:name,:email,:company,:interest,:message,:status,:created_at)", + row, + ) + row["id"] = cur.lastrowid + return row + + +def list_leads() -> list[dict]: + with db() as conn: + return [dict(r) for r in conn.execute("SELECT * FROM leads ORDER BY id DESC")] + + +def set_status(lead_id: int, status: str) -> bool: + if status not in PIPELINE: + raise ValueError(f"status must be one of {PIPELINE}") + with db() as conn: + cur = conn.execute("UPDATE leads SET status=? WHERE id=?", (status, lead_id)) + return cur.rowcount > 0 + + +def pipeline_counts() -> dict: + counts = {s: 0 for s in PIPELINE} + with db() as conn: + for r in conn.execute("SELECT status, COUNT(*) c FROM leads GROUP BY status"): + counts[r["status"]] = r["c"] + return counts + + +# ------------------------------------------------------------------------- views +CSS = """ +:root{--bg:#0b0d10;--panel:#14181d;--line:#232a31;--ink:#e9edf1;--mut:#8b97a3;--acc:#e07a3f} +*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink); +font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif} +a{color:var(--acc);text-decoration:none}.wrap{max-width:980px;margin:0 auto;padding:0 24px} +header{border-bottom:1px solid var(--line)}header .wrap{display:flex;align-items:center; +justify-content:space-between;height:64px}.brand{font-weight:700;letter-spacing:.02em} +.brand b{color:var(--acc)}nav a{margin-left:20px;color:var(--mut)}nav a:hover{color:var(--ink)} +.hero{padding:88px 0 64px;text-align:center}.hero h1{font-size:44px;line-height:1.1;margin:0 0 16px} +.hero p{font-size:19px;color:var(--mut);max-width:640px;margin:0 auto 32px} +.btn{display:inline-block;background:var(--acc);color:#fff;padding:12px 22px;border-radius:8px;font-weight:600} +.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--ink);margin-left:10px} +.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin:48px 0} +.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:22px} +.card h3{margin:0 0 6px;font-size:17px}.card p{margin:0;color:var(--mut);font-size:14px} +.price{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:24px 0 56px} +.price .card{text-align:center}.price .amt{font-size:30px;font-weight:700;margin:8px 0} +.price .amt small{font-size:14px;color:var(--mut);font-weight:400} +section h2{font-size:13px;text-transform:uppercase;letter-spacing:.14em;color:var(--mut);margin:48px 0 0} +form{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px;margin:20px 0 72px} +label{display:block;font-size:13px;color:var(--mut);margin:12px 0 4px} +input,select,textarea{width:100%;background:#0e1216;border:1px solid var(--line);color:var(--ink); +border-radius:8px;padding:10px 12px;font:inherit}textarea{min-height:84px;resize:vertical} +table{width:100%;border-collapse:collapse;margin:16px 0}th,td{text-align:left;padding:10px 8px; +border-bottom:1px solid var(--line);font-size:14px}th{color:var(--mut);font-weight:600} +.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;border:1px solid var(--line)} +.s-new{color:#7aa2ff}.s-contacted{color:#e0c23f}.s-qualified{color:var(--acc)} +.s-won{color:#4fbf72}.s-lost{color:#8b97a3} +.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:20px 0} +.kpi{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;text-align:center} +.kpi b{display:block;font-size:26px}.kpi span{color:var(--mut);font-size:12px;text-transform:uppercase} +footer{border-top:1px solid var(--line);color:var(--mut);font-size:13px;padding:28px 0;text-align:center} +.note{color:var(--mut);font-size:13px} +""" + +CAPABILITIES = [ + ("Digital Twin", "3D BIM infrastructure intelligence — real-time spatial data at industrial scale."), + ("Construction ERP", "Autonomous cost estimation, BOQ generation, CAD/BIM takeoff."), + ("Agent Orchestration", "Multi-agent swarms — agents hiring agents via the A2A protocol."), + ("Sovereign AI", "Self-hosted LLM deployment on private infrastructure — your models, your silicon."), +] + +PRICING = [ + ("Gateway", "$0", "/mo to start", "Branded OpenAI-compatible API at api.allerion.io. Pay only for usage."), + ("Team", "$2k", "/mo", "Virtual keys, budgets, metered Stripe billing, priority routing."), + ("Sovereign", "Custom", "", "Self-hosted GPU deployment + on-prem agent orchestration."), +] + + +def page(title: str, body: str) -> bytes: + doc = f""" + +{html.escape(title)} +
+{body} +
Allerion Systems — autonomous intelligence for the built world. We don't sell software. We deploy intelligence.
+""" + return doc.encode() + + +def landing() -> bytes: + caps = "".join( + f'

{html.escape(t)}

{html.escape(d)}

' + for t, d in CAPABILITIES + ) + price = "".join( + f'

{html.escape(n)}

' + f'
{html.escape(a)} {html.escape(p)}
' + f'

{html.escape(d)}

' + for n, a, p, d in PRICING + ) + body = f""" +
+

Autonomous intelligence
for the built world.

+

We design, deploy, and operate multi-agent AI systems for infrastructure, + construction, and industrial operations. Our agents estimate. Our agents build. + Our agents never sleep.

+ Request access + Open CRM +
+
+

Capabilities

+
{caps}
+

Pricing

+
{price}
+

Request access

+
+ + + + + + +

+

Submitting creates a lead in the embedded CRM.

+
+
""" + return page("Allerion — Autonomous AI for the built world", body) + + +def crm_page() -> bytes: + counts = pipeline_counts() + kpis = "".join( + f'
{counts[s]}{s}
' for s in PIPELINE + ) + rows = [] + for L in list_leads(): + opts = "".join( + f'' + for s in PIPELINE + ) + rows.append( + f"#{L['id']}{html.escape(L['name'])}
" + f"{html.escape(L['email'])}" + f"{html.escape(L['company'] or '—')}" + f"{html.escape(L['interest'] or '—')}" + f"{L['status']}" + f"
" + f"" + f"
" + ) + table = ( + "" + "" + ("".join(rows) or + "") + + "
IDLeadCompanyInterestStatusUpdate
No leads yet — submit the form on the landing page.
" + ) + body = f"""
+

CRM — pipeline

+
{kpis}
+ {table} +

Embedded CRM over the same SQLite store. JSON API at /api/leads.

+
""" + return page("Allerion CRM", body) + + +# ----------------------------------------------------------------------- handler +class Handler(BaseHTTPRequestHandler): + server_version = "AllerionPlatform/0.1" + + def log_message(self, *a): # quieter logs + pass + + def _send(self, code: int, body: bytes, ctype="text/html; charset=utf-8"): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _json(self, code: int, obj): + self._send(code, json.dumps(obj, indent=2).encode(), "application/json") + + def _body_params(self) -> dict: + n = int(self.headers.get("Content-Length", 0) or 0) + raw = self.rfile.read(n).decode() if n else "" + ctype = self.headers.get("Content-Type", "") + if "application/json" in ctype: + try: + return json.loads(raw or "{}") + except json.JSONDecodeError: + return {} + return {k: v[0] for k, v in urllib.parse.parse_qs(raw).items()} + + def do_GET(self): + path = urllib.parse.urlparse(self.path).path + if path == "/": + self._send(200, landing()) + elif path == "/crm": + self._send(200, crm_page()) + elif path == "/api/leads": + self._json(200, {"leads": list_leads()}) + elif path == "/healthz": + self._json(200, {"ok": True}) + else: + self._send(404, page("Not found", '

404

')) + + def do_POST(self): + path = urllib.parse.urlparse(self.path).path + params = self._body_params() + wants_json = "application/json" in self.headers.get("Content-Type", "") + + if path == "/api/leads": + try: + lead = create_lead(params) + except ValueError as e: + return self._json(400, {"error": str(e)}) + if wants_json: + return self._json(201, lead) + # browser form post → redirect to CRM + self.send_response(303) + self.send_header("Location", "/crm") + self.end_headers() + return + + if path.startswith("/api/leads/") and path.endswith("/status"): + try: + lead_id = int(path.split("/")[3]) + except (IndexError, ValueError): + return self._json(404, {"error": "bad lead id"}) + try: + ok = set_status(lead_id, (params.get("status") or "").strip()) + except ValueError as e: + return self._json(400, {"error": str(e)}) + if not ok: + return self._json(404, {"error": "lead not found"}) + if wants_json: + return self._json(200, {"id": lead_id, "status": params["status"]}) + self.send_response(303) + self.send_header("Location", "/crm") + self.end_headers() + return + + self._json(404, {"error": "not found"}) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--port", type=int, default=8099) + ap.add_argument("--host", default="127.0.0.1") + args = ap.parse_args() + init_db() + srv = ThreadingHTTPServer((args.host, args.port), Handler) + print(f"Allerion platform on http://{args.host}:{args.port} (CRM at /crm)") + try: + srv.serve_forever() + except KeyboardInterrupt: + srv.shutdown() + + +if __name__ == "__main__": + main() From 89df5cfee2fbf89907ff805d5a0da274e954739b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 03:57:10 +0000 Subject: [PATCH 07/14] Embed gateway connector into an 80-agent fleet Add agentmarket.connector.Connector (per-agent handle to the Allerion gateway, live-or-stub) and an optional connector field on both agent types. run_fleet.py spins up 80 agents (30 merchants + 50 buyers), embeds a connector into each, runs the A2A market, and fulfils every deal through the selling merchant's connector. Verified: 80/80 wired, 197 deals, money conserved; existing 7 tests still pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- apps/agent-market/agentmarket/agents.py | 4 +- apps/agent-market/agentmarket/connector.py | 44 +++++++ apps/agent-market/run_fleet.py | 122 ++++++++++++++++++ .../test_market.cpython-311-pytest-9.1.0.pyc | Bin 0 -> 17529 bytes 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 apps/agent-market/agentmarket/connector.py create mode 100644 apps/agent-market/run_fleet.py create mode 100644 apps/agent-market/tests/__pycache__/test_market.cpython-311-pytest-9.1.0.pyc diff --git a/apps/agent-market/agentmarket/agents.py b/apps/agent-market/agentmarket/agents.py index 2976f64..713f675 100644 --- a/apps/agent-market/agentmarket/agents.py +++ b/apps/agent-market/agentmarket/agents.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass, field -from typing import List, Optional +from typing import Any, List, Optional from .protocol import Need, Service @@ -14,6 +14,7 @@ class MerchantAgent: services: List[Service] = field(default_factory=list) concession: float = 0.4 # how fast it moves from ask toward floor (0..1) sales: int = 0 + connector: Optional[Any] = None # gateway connector (see connector.Connector) def offer(self, service: Service) -> "MerchantAgent": self.services.append(service) @@ -39,6 +40,7 @@ class BuyerAgent: quality_weight: float = 0.5 # how much it trades price for quality concession: float = 0.4 purchases: int = 0 + connector: Optional[Any] = None # gateway connector (see connector.Connector) def score(self, need: Need, service: Service) -> float: """Expected utility of a supplier before negotiating: value adjusted diff --git a/apps/agent-market/agentmarket/connector.py b/apps/agent-market/agentmarket/connector.py new file mode 100644 index 0000000..5c9d02f --- /dev/null +++ b/apps/agent-market/agentmarket/connector.py @@ -0,0 +1,44 @@ +"""The Allerion gateway connector — embedded into every agent. + +A Connector is an agent's handle to the Allerion AI gateway (the +OpenAI-compatible endpoint from infra/ai-gateway). Each agent carries its own +Connector, so fulfilment and reasoning route through the shared gateway — +metered, rate-limited, and billable per agent. When no gateway/key is +configured the connector falls back to deterministic stubs, so a whole fleet +runs offline with zero credentials. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from . import llm + + +@dataclass +class Connector: + """Per-agent connection to the gateway.""" + + agent_id: str + model: str = llm.MODEL + calls: int = 0 + + @property + def live(self) -> bool: + return llm.live() + + def status(self) -> str: + return f"live:{self.model}" if self.live else "stub" + + def fulfill(self, capability: str, prompt: str) -> str: + """Route a unit of work through the gateway on this agent's behalf.""" + self.calls += 1 + return llm.fulfill(capability, prompt) + + +def attach(agents) -> int: + """Embed a fresh Connector into each agent. Returns the count wired.""" + n = 0 + for a in agents: + a.connector = Connector(agent_id=a.id) + n += 1 + return n diff --git a/apps/agent-market/run_fleet.py b/apps/agent-market/run_fleet.py new file mode 100644 index 0000000..ef27e10 --- /dev/null +++ b/apps/agent-market/run_fleet.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Allerion Agent Market — 80-agent fleet on the gateway connector. + +Spins up 80 agents (30 merchants + 50 buyers), embeds a gateway Connector into +each one, runs the A2A market, and fulfils every closed deal through the +merchant's connector. Demonstrates "the connector embedded into 80 agents": +all 80 route work through the shared Allerion gateway (live with a key, stub +without). + + python3 run_fleet.py + LLM_BASE_URL=https://openrouter.ai/api LLM_API_KEY=sk-or-... \ + LLM_MODEL=openai/gpt-4o-mini python3 run_fleet.py +""" +from __future__ import annotations + +import random + +from agentmarket import connector, llm +from agentmarket.agents import BuyerAgent, MerchantAgent +from agentmarket.ledger import Ledger +from agentmarket.market import Market +from agentmarket.marketplace import Marketplace +from agentmarket.protocol import Need, Service + +N_MERCHANTS = 30 +N_BUYERS = 50 +ROUNDS = 4 +SEED = 11 + +CAPS = ["summarize", "translate", "enrich", "copywrite", "classify", "extract"] +PROMPTS = { + "summarize": "summarize this quarter's field report", + "translate": "translate the spec sheet to German", + "enrich": "enrich 200 supplier records with firmographics", + "copywrite": "write launch copy for the new module", + "classify": "classify these 500 support tickets", + "extract": "extract line items from these invoices", +} + + +def build_fleet(): + rng = random.Random(SEED) + mp = Marketplace() + merchants = [] + for i in range(N_MERCHANTS): + m = MerchantAgent(f"m{i:02d}", f"Vendor-{i:02d}", concession=rng.uniform(0.3, 0.55)) + # each merchant offers 1-2 capabilities + for cap in rng.sample(CAPS, k=rng.randint(1, 2)): + base = rng.uniform(6, 22) + m.offer(Service( + id=f"{m.id}-{cap}", name=f"{cap} by {m.name}", capability=cap, + merchant_id=m.id, list_price=round(base, 2), + floor_price=round(base * rng.uniform(0.5, 0.7), 2), + quality=round(rng.uniform(0.55, 0.97), 2), + capacity_per_round=rng.randint(2, 6), + )) + mp.register(m) + merchants.append(m) + + ledger = Ledger(house_id="allerion", fee_rate=0.10) + market = Market(marketplace=mp, ledger=ledger) + buyers = [] + for i in range(N_BUYERS): + b = BuyerAgent(f"b{i:02d}", f"Buyer-{i:02d}", + quality_weight=rng.uniform(0.2, 0.8), concession=rng.uniform(0.3, 0.5)) + b.needs = [ + Need(f"{b.id}-n{r}", b.id, (cap := rng.choice(CAPS)), + value=round(rng.uniform(8, 26), 2), prompt=PROMPTS[cap]) + for r in range(ROUNDS) + ] + market.add_buyer(b, opening_balance=round(rng.uniform(120, 320), 2)) + buyers.append(b) + + # Embed the gateway connector into every agent — this is the "80 agents". + wired = connector.attach(merchants) + connector.attach(buyers) + return market, merchants, buyers, wired + + +def main() -> None: + random.seed(SEED) + market, merchants, buyers, wired = build_fleet() + + print() + print("=" * 70) + print(" ALLERION FLEET — agent-to-agent commerce on the gateway connector") + print(f" agents wired to connector: {wired} ({len(merchants)} merchants + {len(buyers)} buyers)") + print(f" gateway: {'LIVE via ' + llm.MODEL if llm.live() else 'deterministic stub (no key)'}") + print("=" * 70) + + # Run the market WITHOUT auto-fulfil; we fulfil via each merchant's connector. + market.run(ROUNDS, fulfill=False) + deals = market.all_deals() + + # Fulfil each closed deal through the selling merchant's embedded connector. + by_id = {m.id: m for m in merchants} + for d in deals: + conn = by_id[d.merchant_id].connector + d.delivered = conn.fulfill(d.capability, PROMPTS.get(d.capability, d.capability)) + + s = market.summary() + active = sum(1 for m in merchants if m.connector.calls) + \ + sum(1 for b in buyers if b.purchases) + total_calls = sum(m.connector.calls for m in merchants) + + print(f"\n rounds: {ROUNDS} deals: {s['deals']} GMV: ${s['gmv']:.2f}") + print(f" platform revenue: ${s['platform_revenue']:.2f} (10% of GMV)") + print(f" connector calls through gateway: {total_calls}") + print(f" agents that transacted: {active}/{wired}") + print(f" money conserved: ${s['money_conserved']:.2f}") + + print("\n Top 5 merchants by gateway calls:") + for m in sorted(merchants, key=lambda x: -x.connector.calls)[:5]: + print(f" {m.id} {m.name:<11} {m.connector.status():<22} calls={m.connector.calls} sales={m.sales}") + + if deals and deals[0].delivered: + print("\n Sample delivered work (via connector):") + print(" " + deals[0].delivered[:160]) + print() + + +if __name__ == "__main__": + main() diff --git a/apps/agent-market/tests/__pycache__/test_market.cpython-311-pytest-9.1.0.pyc b/apps/agent-market/tests/__pycache__/test_market.cpython-311-pytest-9.1.0.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a08ad983a54c8447ac7e0451f076faf19ee8ac2 GIT binary patch literal 17529 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 Gw*L=cu6arT literal 0 HcmV?d00001 From c310603e2d54c42b77761cfbc7fbb8fac5c22501 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 04:02:33 +0000 Subject: [PATCH 08/14] Redesign platform UX: NVIDIA x built-robotics theme Signal-green (#76B900) on near-black, blueprint grid backdrop, industrial corner-bracket spec cards, monospace technical labels, live telemetry band with animated EQ bars, pulsing status indicators, glow/lift interactions, and a console-style CRM. Pure CSS, no dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- apps/platform/server.py | 184 +++++++++++++++++++++++++++++----------- 1 file changed, 133 insertions(+), 51 deletions(-) diff --git a/apps/platform/server.py b/apps/platform/server.py index 15820fa..2acfbba 100644 --- a/apps/platform/server.py +++ b/apps/platform/server.py @@ -103,38 +103,105 @@ def pipeline_counts() -> dict: # ------------------------------------------------------------------------- views CSS = """ -:root{--bg:#0b0d10;--panel:#14181d;--line:#232a31;--ink:#e9edf1;--mut:#8b97a3;--acc:#e07a3f} -*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink); -font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif} -a{color:var(--acc);text-decoration:none}.wrap{max-width:980px;margin:0 auto;padding:0 24px} -header{border-bottom:1px solid var(--line)}header .wrap{display:flex;align-items:center; -justify-content:space-between;height:64px}.brand{font-weight:700;letter-spacing:.02em} -.brand b{color:var(--acc)}nav a{margin-left:20px;color:var(--mut)}nav a:hover{color:var(--ink)} -.hero{padding:88px 0 64px;text-align:center}.hero h1{font-size:44px;line-height:1.1;margin:0 0 16px} -.hero p{font-size:19px;color:var(--mut);max-width:640px;margin:0 auto 32px} -.btn{display:inline-block;background:var(--acc);color:#fff;padding:12px 22px;border-radius:8px;font-weight:600} -.btn.ghost{background:transparent;border:1px solid var(--line);color:var(--ink);margin-left:10px} -.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:16px;margin:48px 0} -.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:22px} -.card h3{margin:0 0 6px;font-size:17px}.card p{margin:0;color:var(--mut);font-size:14px} -.price{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:24px 0 56px} -.price .card{text-align:center}.price .amt{font-size:30px;font-weight:700;margin:8px 0} -.price .amt small{font-size:14px;color:var(--mut);font-weight:400} -section h2{font-size:13px;text-transform:uppercase;letter-spacing:.14em;color:var(--mut);margin:48px 0 0} -form{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px;margin:20px 0 72px} -label{display:block;font-size:13px;color:var(--mut);margin:12px 0 4px} -input,select,textarea{width:100%;background:#0e1216;border:1px solid var(--line);color:var(--ink); -border-radius:8px;padding:10px 12px;font:inherit}textarea{min-height:84px;resize:vertical} -table{width:100%;border-collapse:collapse;margin:16px 0}th,td{text-align:left;padding:10px 8px; -border-bottom:1px solid var(--line);font-size:14px}th{color:var(--mut);font-weight:600} -.pill{display:inline-block;padding:2px 10px;border-radius:999px;font-size:12px;border:1px solid var(--line)} -.s-new{color:#7aa2ff}.s-contacted{color:#e0c23f}.s-qualified{color:var(--acc)} -.s-won{color:#4fbf72}.s-lost{color:#8b97a3} -.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:20px 0} -.kpi{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px;text-align:center} -.kpi b{display:block;font-size:26px}.kpi span{color:var(--mut);font-size:12px;text-transform:uppercase} -footer{border-top:1px solid var(--line);color:var(--mut);font-size:13px;padding:28px 0;text-align:center} -.note{color:var(--mut);font-size:13px} +:root{ + --bg:#07090a;--bg2:#0b0f0c;--panel:#0f1411;--line:#1f2a22;--line2:#2b3a2e; + --ink:#e9f1ea;--mut:#7e8d82;--nv:#76b900;--nv2:#b6ff3a;--glow:rgba(118,185,0,.30); + --mono:ui-monospace,"SFMono-Regular","JetBrains Mono",Menlo,Consolas,monospace +} +*{box-sizing:border-box}html{scroll-behavior:smooth} +body{margin:0;background:var(--bg);color:var(--ink); + font:16px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; + -webkit-font-smoothing:antialiased} +body::before{content:"";position:fixed;inset:0;z-index:-2; + background:radial-gradient(900px 520px at 50% -12%,rgba(118,185,0,.12),transparent 70%), + linear-gradient(var(--bg2),var(--bg))} +body::after{content:"";position:fixed;inset:0;z-index:-1;opacity:.55; + background-image:linear-gradient(rgba(118,185,0,.05) 1px,transparent 1px), + linear-gradient(90deg,rgba(118,185,0,.05) 1px,transparent 1px); + background-size:46px 46px;-webkit-mask-image:radial-gradient(ellipse at 50% 0,#000,transparent 82%); + mask-image:radial-gradient(ellipse at 50% 0,#000,transparent 82%)} +a{color:var(--nv);text-decoration:none} +.wrap{max-width:1040px;margin:0 auto;padding:0 24px} +.mono{font-family:var(--mono);text-transform:uppercase;letter-spacing:.18em;font-size:12px;color:var(--mut)} +.nv{color:var(--nv)} +header{position:sticky;top:0;z-index:10;backdrop-filter:blur(10px); + background:rgba(7,9,10,.72);border-bottom:1px solid var(--line)} +header .wrap{display:flex;align-items:center;justify-content:space-between;height:62px} +.brand{font-family:var(--mono);font-weight:700;letter-spacing:.22em;font-size:14px} +.brand .mk{color:var(--nv)} +nav a{margin-left:22px;font-family:var(--mono);font-size:12px;letter-spacing:.14em; + text-transform:uppercase;color:var(--mut)} +nav a:hover{color:var(--ink)} +.dot{display:inline-block;width:8px;height:8px;border-radius:50%;background:var(--nv); + box-shadow:0 0 0 0 var(--glow);animation:pulse 2s infinite} +@keyframes pulse{0%{box-shadow:0 0 0 0 var(--glow)}70%{box-shadow:0 0 0 10px transparent}100%{box-shadow:0 0 0 0 transparent}} +.hero{position:relative;padding:98px 0 56px;text-align:center} +.hero .ey{display:inline-flex;align-items:center;gap:10px;margin-bottom:22px; + padding:6px 14px;border:1px solid var(--line2);border-radius:999px;background:var(--panel)} +.hero h1{font-size:56px;line-height:1.03;margin:0 0 18px;letter-spacing:-.025em;font-weight:800} +.hero h1 .g{background:linear-gradient(90deg,var(--nv),var(--nv2));-webkit-background-clip:text; + background-clip:text;color:transparent} +.hero p{font-size:19px;color:var(--mut);max-width:660px;margin:0 auto 30px} +.cta{display:inline-flex;gap:12px;flex-wrap:wrap;justify-content:center} +.btn{display:inline-block;font-family:var(--mono);font-size:13px;letter-spacing:.1em;text-transform:uppercase; + background:var(--nv);color:#06140a;padding:13px 24px;border-radius:8px;font-weight:700;border:1px solid var(--nv); + transition:transform .15s,box-shadow .15s,background .15s;box-shadow:0 0 24px var(--glow);cursor:pointer} +.btn:hover{transform:translateY(-2px);box-shadow:0 0 38px var(--glow);background:var(--nv2);border-color:var(--nv2)} +.btn.ghost{background:transparent;color:var(--ink);border-color:var(--line2);box-shadow:none} +.btn.ghost:hover{border-color:var(--nv);color:var(--nv)} +section .lbl{display:flex;align-items:center;gap:14px;margin:62px 0 22px; + font-family:var(--mono);text-transform:uppercase;letter-spacing:.18em;font-size:12px;color:var(--mut)} +section .lbl::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line2),transparent)} +.grid{display:grid;grid-template-columns:repeat(2,1fr);gap:14px} +.card{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:24px; + transition:border-color .2s,transform .2s,box-shadow .2s;overflow:hidden} +.card::before{content:"";position:absolute;left:0;top:0;width:32px;height:32px; + border-top:2px solid var(--nv);border-left:2px solid var(--nv);opacity:.45;transition:opacity .2s} +.card::after{content:"";position:absolute;right:0;bottom:0;width:32px;height:32px; + border-bottom:2px solid var(--nv);border-right:2px solid var(--nv);opacity:.45;transition:opacity .2s} +.card:hover{border-color:var(--line2);transform:translateY(-3px);box-shadow:0 14px 44px rgba(0,0,0,.55)} +.card:hover::before,.card:hover::after{opacity:1} +.card .ix{font-family:var(--mono);font-size:12px;color:var(--nv);letter-spacing:.16em} +.card h3{margin:8px 0 6px;font-size:18px} +.card p{margin:0;color:var(--mut);font-size:14px} +.telem{display:grid;grid-template-columns:repeat(4,1fr);gap:14px;margin:22px 0} +.telem .t{position:relative;background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px} +.telem .k{font-family:var(--mono);font-size:11px;color:var(--mut);letter-spacing:.14em;text-transform:uppercase} +.telem .v{font-size:26px;font-weight:800;margin-top:4px} +.telem .v small{color:var(--nv);font-size:13px;font-weight:600} +.bars{display:flex;gap:3px;align-items:flex-end;height:24px;margin-top:12px} +.bars i{flex:1;background:var(--nv);opacity:.7;border-radius:1px;height:30%;animation:eq 1.2s ease-in-out infinite} +.bars i:nth-child(2){animation-delay:.12s}.bars i:nth-child(3){animation-delay:.24s} +.bars i:nth-child(4){animation-delay:.36s}.bars i:nth-child(5){animation-delay:.48s} +.bars i:nth-child(6){animation-delay:.6s}.bars i:nth-child(7){animation-delay:.72s} +@keyframes eq{0%,100%{height:25%}50%{height:100%}} +.price{display:grid;grid-template-columns:repeat(3,1fr);gap:14px} +.price .card{text-align:center} +.price .card.feat{border-color:var(--nv);box-shadow:0 0 30px var(--glow)} +.price .amt{font-size:32px;font-weight:800;margin:10px 0} +.price .amt small{font-size:13px;color:var(--mut);font-weight:500;font-family:var(--mono)} +form{background:var(--panel);border:1px solid var(--line);border-radius:14px;padding:26px;margin:18px 0 80px} +label{display:block;font-family:var(--mono);font-size:11px;letter-spacing:.12em;text-transform:uppercase; + color:var(--mut);margin:14px 0 6px} +input,select,textarea{width:100%;background:#070a08;border:1px solid var(--line2);color:var(--ink); + border-radius:8px;padding:11px 13px;font:inherit;transition:border-color .15s,box-shadow .15s} +input:focus,select:focus,textarea:focus{outline:none;border-color:var(--nv);box-shadow:0 0 0 3px var(--glow)} +textarea{min-height:88px;resize:vertical} +table{width:100%;border-collapse:collapse;margin:16px 0} +th,td{text-align:left;padding:12px 10px;border-bottom:1px solid var(--line);font-size:14px} +th{font-family:var(--mono);font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:var(--mut)} +td .em{color:var(--mut);font-size:12px;font-family:var(--mono)} +.pill{display:inline-block;padding:3px 11px;border-radius:999px;font-size:11px;font-family:var(--mono); + text-transform:uppercase;letter-spacing:.08em;border:1px solid var(--line2)} +.s-new{color:#7ab8ff;border-color:#274a6b}.s-contacted{color:var(--nv2);border-color:#3a4a16} +.s-qualified{color:var(--nv);border-color:#2d4a12}.s-won{color:#39e07d;border-color:#1d5a35}.s-lost{color:#8b97a3} +.kpis{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:18px 0} +.kpi{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:16px;text-align:center} +.kpi b{display:block;font-size:30px;font-weight:800;color:var(--nv)} +.kpi span{font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--mut)} +footer{border-top:1px solid var(--line);color:var(--mut);padding:30px 0;text-align:center} +.note{color:var(--mut);font-size:12px;font-family:var(--mono);letter-spacing:.04em} +code{font-family:var(--mono);color:var(--nv);font-size:13px} """ CAPABILITIES = [ @@ -156,41 +223,53 @@ def page(title: str, body: str) -> bytes: {html.escape(title)}
-
▲ ALLERION.io
- +
ALLERION.IO
+
{body} -
Allerion Systems — autonomous intelligence for the built world. We don't sell software. We deploy intelligence.
+
Allerion Systems — autonomous intelligence for the built world
+
We don't sell software. We deploy intelligence.
""" return doc.encode() def landing() -> bytes: caps = "".join( - f'

{html.escape(t)}

{html.escape(d)}

' - for t, d in CAPABILITIES + f'
{i:02d}
' + f'

{html.escape(t)}

{html.escape(d)}

' + for i, (t, d) in enumerate(CAPABILITIES, 1) ) price = "".join( - f'

{html.escape(n)}

' + f'
{html.escape(n)}
' f'
{html.escape(a)} {html.escape(p)}
' f'

{html.escape(d)}

' - for n, a, p, d in PRICING + for i, (n, a, p, d) in enumerate(PRICING) ) + bars = "".join("" for _ in range(7)) body = f"""
-

Autonomous intelligence
for the built world.

+
Autonomous systems · online
+

Autonomous intelligence
for the built world.

We design, deploy, and operate multi-agent AI systems for infrastructure, construction, and industrial operations. Our agents estimate. Our agents build. Our agents never sleep.

- Request access - Open CRM +
-

Capabilities

+
+
Agents online
80
+
Models routed
80+ NIM
+
Fleet uptime
99.9%
+
Throughput
{bars}
+
+
// Capabilities
{caps}
-

Pricing

+
// Pricing
{price}
-

Request access

+
// Request access
@@ -201,8 +280,8 @@ def landing() -> bytes: -

-

Submitting creates a lead in the embedded CRM.

+

+

// submitting creates a lead in the embedded CRM

""" return page("Allerion — Autonomous AI for the built world", body) @@ -236,13 +315,16 @@ def crm_page() -> bytes: "No leads yet — submit the form on the landing page.") + "" ) - body = f"""
-

CRM — pipeline

+ body = f"""
+
+ CRM console · live
+
// Pipeline telemetry
{kpis}
{table} -

Embedded CRM over the same SQLite store. JSON API at /api/leads.

+

// embedded CRM over the same SQLite store · JSON API at /api/leads

""" - return page("Allerion CRM", body) + return page("Allerion Console — CRM", body) # ----------------------------------------------------------------------- handler From e6ba8a63ff7d8b647a54925d31a6dd4345dfbd38 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 04:04:42 +0000 Subject: [PATCH 09/14] Wire Stripe Checkout into the platform Add billing.py (stdlib urllib -> Stripe REST) and a /buy/ route that creates a Stripe Checkout Session and redirects the buyer to it. Pricing cards now carry Subscribe CTAs. Key-configurable via STRIPE_API_KEY (+ optional STRIPE_PRICE_TEAM, PLATFORM_BASE_URL); degrades to a friendly 'configuring' page until the key is set. Verified: routing, graceful no-key path, 404 on unknown tier. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- .../__pycache__/billing.cpython-311.pyc | Bin 0 -> 4735 bytes apps/platform/billing.py | 83 ++++++++++++++++++ apps/platform/server.py | 50 +++++++++-- 3 files changed, 127 insertions(+), 6 deletions(-) create mode 100644 apps/platform/__pycache__/billing.cpython-311.pyc create mode 100644 apps/platform/billing.py diff --git a/apps/platform/__pycache__/billing.cpython-311.pyc b/apps/platform/__pycache__/billing.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8469f4c5a11ac4097148c6b7c1c45371200f295f GIT binary patch literal 4735 zcmbVPU2NOd6}}WHiV`i^@_&=YX6mlCmRgFP#*OVJnd`}I*48yFwL|T?3Qf{BZHiPc z$+!{Z&M5lO78%+F8QiT6=9gtDl7~EOfFJrWV9l^cDi9EXK!9LFANr=EeF*Zjb1BKT z@`Dw-A`kD+x#ymH?z!iD`b#(*LQpvU*y>Flq5qIZy|C2^^@C0!w1s3ehh$3jX;juX z=c7oS*62Cf`!aKk_w~=Q-ZwDED$FWZ7quO%*Y``>H1_XD@21B#^C8a zx}<0VR%8_`l4b5!XJ+=yWHOUZPQ2zx7%*}c*hkc2f~X71d#Y&>`_#-h1Ljf$Ro*>{ zv?69bMz1Ha{XCDw^m||>(|6Z zMRPggT1rNhT~^E*wr;tcs#^*^Cu%MWr)*>0Wfl!XlWLdG`_Mq)rGEH1RJM?{do>ZV zcEbchjn_7XzM`gMKHLIxFazW(^aD_I|43rR$SMijRB$4`4CiAnCd6FMOwl#9O__%) z@+@3Jys#kc(;5X}inpT=pt6Mu5ICQJTD9ON+1J>!sej1Zlm~RIUgX6K3tT}&lVct8fg_tkj)!6^8E$WtPonLUN zAsFpz9Ic+QEL!5iykujn=n`pJbsR?t5&A! zNDDYjnB$LUhD#30z{OaYulw8rj=&_iAv7FOjKW>eGRZj!xC5xmLwu>*1NT5rKVdiv zWD}JGp^wHs{7nT>{spQW?Z4V~b?$nv(=zIe&pXX;JKWoMxsh`4#7^JN^4HHg+)L%) z(BF>TFn3P;b)$GJSvr<$wrh zHZAa5oKdo(s@)HP#zH}uHgrYWcL(-7>mBefxGONXUbgppV7(K3uhC03dwh_M)rZ;fY-|{GaGaP zj;BCx*8oU;-Rc+onp%2b6$%w-t9g&F@W6N;{AGi-$c?U*)m@z*4LRgc12=I_uWUaO zMil5qduXoMvetCl}us=ngZE|NHDstQP_C`%N0uA2TQD9d|fcFY` zVrL-$mS-C06xa`7e;e%k>^ilMsym5wzP>(4pz9#$2w4NxkYVxggbg4Ht9g$t-vsE=0w_6{KA%%uX6E%&Iz~h4 zg?!@%RsaMk*mT*f0(0c=h$BIK4_Ine0US2%1^`lNqNG44>memGAFdsX*A();F(KCF z`qO8UvniKeRxCI4rl{FUlHe*23b;+P09`<$*X43`v_`o{SDuVbURNRz4|l|c#4ssQe?>C zh5#mgJaS=#u)IOti5zearizhtDUx=$bcIF1!{uP-;QpBkbl>r3rDJMATTBi+mdXB~rx?OMhJY!^)Q{#kT%ZTYrUyF^^%7d{6j)BxJAeQMG&b?men|ukYTs zeDuN#CH`;)`9Qz@v>a{S>b%(byY5TfTfG;1uLeKsEk=h+(czC+?`%$99lAc^96Sln zjW?^`V&qgQa_a9B4wr|S8r*bdX~QV^aMTCPye&u4-LTOBu#nXJjQsQin~dN*Gm8@CJ89UR^6E(psG)k zbX8>{?6Foikjcn~l*zcEOlHZ3P^)Az7@!Zrr-8UW!*rXf7cuVLI(&k3M`~TWSF`pP z6%%K7{a*a4UPcyI8zdg%VM0i*@NT$wJ8&Cmk}%_4q5Iqm5PNM+Ifc{EM-Z3!GoWzl z6jkx@6jMQjbR(+IK~L>}?jnBE`;}2})BBZCn^S+vsKcp0ccX2W4s7*Y>~VynH=Zg+ zkC&pyZ%4;(MaPTLlcng%%}ALKUl{xJ)F-E$1A|Uts>r`o;$OPWPv7FF|78~WREbY* za+MJB^W~^eZtuMGtF7sa(@x*Wj<480UTPn|-9B-veWKVtS!$mww|1A?55heR12!du g4g`Ih{z^MV3E+ASgbPx@)JVDoin!wcrk)x93&1w3ng9R* literal 0 HcmV?d00001 diff --git a/apps/platform/billing.py b/apps/platform/billing.py new file mode 100644 index 0000000..d6f794b --- /dev/null +++ b/apps/platform/billing.py @@ -0,0 +1,83 @@ +"""Stripe Checkout for the Allerion platform — stdlib only. + +Creates Stripe Checkout Sessions for a pricing tier via Stripe's REST API +(no `stripe` package needed). Configure with environment variables: + + STRIPE_API_KEY your Stripe secret key (sk_test_... / sk_live_...) + PLATFORM_BASE_URL public URL for success/cancel redirects + STRIPE_PRICE_TEAM (optional) an existing Stripe Price id for the Team tier + +Without STRIPE_API_KEY the platform still runs; checkout links report that +billing isn't configured yet instead of erroring. +""" +from __future__ import annotations + +import json +import os +import urllib.parse +import urllib.request + +STRIPE_API_KEY = os.environ.get("STRIPE_API_KEY", "").strip() +BASE_URL = os.environ.get("PLATFORM_BASE_URL", "http://127.0.0.1:8099").rstrip("/") + +# Sellable tiers. If a tier has a Stripe price id (env), it's used directly; +# otherwise a price is created inline from `amount` (cents) / `interval`. +TIERS = { + "team": { + "label": "Allerion Team", + "price_id": os.environ.get("STRIPE_PRICE_TEAM", "").strip(), + "amount": 200000, # $2,000.00 / month + "interval": "month", + }, +} + + +def live() -> bool: + return bool(STRIPE_API_KEY) + + +def _line_items(cfg: dict) -> list[tuple[str, str]]: + if cfg["price_id"]: + return [("line_items[0][price]", cfg["price_id"]), ("line_items[0][quantity]", "1")] + return [ + ("line_items[0][price_data][currency]", "usd"), + ("line_items[0][price_data][product_data][name]", cfg["label"]), + ("line_items[0][price_data][unit_amount]", str(cfg["amount"])), + ("line_items[0][price_data][recurring][interval]", cfg["interval"]), + ("line_items[0][quantity]", "1"), + ] + + +def create_checkout(tier: str, customer_email: str | None = None) -> dict: + """Create a Stripe Checkout Session; returns the Stripe object (has `url`).""" + cfg = TIERS.get(tier) + if not cfg: + raise ValueError(f"unknown tier: {tier}") + if not live(): + raise RuntimeError("STRIPE_API_KEY is not set — billing isn't configured") + + params = [ + ("mode", "subscription"), + ("success_url", f"{BASE_URL}/crm?checkout=success"), + ("cancel_url", f"{BASE_URL}/#pricing"), + ("allow_promotion_codes", "true"), + ] + if customer_email: + params.append(("customer_email", customer_email)) + params += _line_items(cfg) + + req = urllib.request.Request( + "https://api.stripe.com/v1/checkout/sessions", + data=urllib.parse.urlencode(params).encode(), + headers={ + "Authorization": f"Bearer {STRIPE_API_KEY}", + "Content-Type": "application/x-www-form-urlencoded", + }, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=20) as r: + return json.loads(r.read()) + except urllib.error.HTTPError as e: + detail = e.read().decode(errors="replace") + raise RuntimeError(f"Stripe error {e.code}: {detail}") from e diff --git a/apps/platform/server.py b/apps/platform/server.py index 2acfbba..d2f06ff 100644 --- a/apps/platform/server.py +++ b/apps/platform/server.py @@ -27,6 +27,8 @@ from datetime import datetime, timezone from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +import billing + DB_PATH = os.environ.get("ALLERION_CRM_DB", os.path.join(os.path.dirname(__file__), "crm.db")) PIPELINE = ["new", "contacted", "qualified", "won", "lost"] @@ -211,10 +213,11 @@ def pipeline_counts() -> dict: ("Sovereign AI", "Self-hosted LLM deployment on private infrastructure — your models, your silicon."), ] +# name, amount, period, description, cta-tier (None → request-access anchor) PRICING = [ - ("Gateway", "$0", "/mo to start", "Branded OpenAI-compatible API at api.allerion.io. Pay only for usage."), - ("Team", "$2k", "/mo", "Virtual keys, budgets, metered Stripe billing, priority routing."), - ("Sovereign", "Custom", "", "Self-hosted GPU deployment + on-prem agent orchestration."), + ("Gateway", "$0", "/mo to start", "Branded OpenAI-compatible API at api.allerion.io. Pay only for usage.", None), + ("Team", "$2k", "/mo", "Virtual keys, budgets, metered Stripe billing, priority routing.", "team"), + ("Sovereign", "Custom", "", "Self-hosted GPU deployment + on-prem agent orchestration.", None), ] @@ -239,11 +242,16 @@ def landing() -> bytes: f'

{html.escape(t)}

{html.escape(d)}

' for i, (t, d) in enumerate(CAPABILITIES, 1) ) + def price_cta(tier): + if tier: + return f'

Subscribe →

' + return '

Request access

' + price = "".join( f'
{html.escape(n)}
' f'
{html.escape(a)} {html.escape(p)}
' - f'

{html.escape(d)}

' - for i, (n, a, p, d) in enumerate(PRICING) + f'

{html.escape(d)}

{price_cta(tier)}
' + for i, (n, a, p, d, tier) in enumerate(PRICING) ) bars = "".join("" for _ in range(7)) body = f""" @@ -363,11 +371,41 @@ def do_GET(self): self._send(200, crm_page()) elif path == "/api/leads": self._json(200, {"leads": list_leads()}) + elif path.startswith("/buy/"): + self._checkout(path.split("/")[2]) elif path == "/healthz": - self._json(200, {"ok": True}) + self._json(200, {"ok": True, "billing": billing.live()}) else: self._send(404, page("Not found", '

404

')) + def _checkout(self, tier: str): + """Create a Stripe Checkout Session and redirect the buyer to it.""" + if tier not in billing.TIERS: + return self._send(404, page("Unknown plan", + '

Unknown plan

' + '

No such tier.

Back to pricing
')) + if not billing.live(): + return self._send(200, page("Checkout — configuring", f""" +
+
Billing · pending key
+

Almost live.

+

Stripe Checkout for the {html.escape(tier.title())} plan is wired and ready — + it just needs the Stripe secret key set as STRIPE_API_KEY. + Once that's in the environment, this button opens Stripe Checkout directly.

+ Request access + Open console +
""")) + try: + session = billing.create_checkout(tier) + except Exception as e: # noqa: BLE001 — surface a friendly page + return self._send(502, page("Checkout error", f""" +

Checkout unavailable

+

{html.escape(str(e))[:300]}

+ Back to pricing
""")) + self.send_response(303) + self.send_header("Location", session["url"]) + self.end_headers() + def do_POST(self): path = urllib.parse.urlparse(self.path).path params = self._body_params() From 1d06d3de7b486cafcde7b3d7838b36e0959a0ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 13:25:22 +0000 Subject: [PATCH 10/14] Add Allerion Skills Store: sell Claude Skills + Codex plugin via Stripe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A zero-dependency storefront that sells productized developer tools as one-time Stripe purchases with instant, license-gated download delivery. Products (real, shippable, under apps/store/products/): - PR Review Pro Claude Skill — severity-ranked diff review + risk-profiler - Changelog Craft Claude Skill — commits → release notes + semver bump - Test Forge Claude Skill — testable-surface mapper + test guidance - Allerion DevKit Codex plugin — MCP server (review_diff/draft_changelog/ scaffold_tests), pure stdlib, local-only, --selftest verified - DevKit Bundle all four, one license Store (apps/store/, stdlib only): - Stripe one-time Checkout (mode=payment); payment verified server-side via session retrieval before any download is handed over - HMAC-signed license tokens tied to buyer email; forged tokens get 403 - Delivery zips built on the fly with a personalized LICENSE.txt + README-FIRST - SQLite order ledger; /api/products, /healthz; storefront in the house theme - setup_stripe.py to mint stable Price ids; STORE_DEV_FULFILLMENT for local tests - tests cover catalog, license signing/tamper, and zip delivery (7 passing) Platform: link the marketing site + CRM to the store (STORE_URL); harden apps/platform/.gitignore against __pycache__ and drop a stray committed .pyc. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01FCybcJmhDsAqMFJsBEL4aj --- apps/platform/.gitignore | 2 + .../__pycache__/billing.cpython-311.pyc | Bin 4735 -> 0 bytes apps/platform/server.py | 5 +- apps/store/.gitignore | 5 + apps/store/README.md | 107 +++++ apps/store/catalog.py | 112 +++++ apps/store/fulfillment.py | 123 ++++++ apps/store/payments.py | 82 ++++ .../claude-skills/changelog-craft/SKILL.md | 72 ++++ .../changelog-craft/scripts/group_commits.py | 100 +++++ .../claude-skills/pr-review-pro/SKILL.md | 84 ++++ .../pr-review-pro/scripts/diffstat.py | 83 ++++ .../claude-skills/test-forge/SKILL.md | 62 +++ .../test-forge/scripts/surface.py | 94 +++++ apps/store/products/codex-devkit/AGENTS.md | 22 + apps/store/products/codex-devkit/README.md | 71 ++++ .../products/codex-devkit/config.example.toml | 6 + apps/store/products/codex-devkit/server.py | 277 +++++++++++++ apps/store/server.py | 385 ++++++++++++++++++ apps/store/setup_stripe.py | 55 +++ apps/store/tests/test_store.py | 86 ++++ 21 files changed, 1832 insertions(+), 1 deletion(-) delete mode 100644 apps/platform/__pycache__/billing.cpython-311.pyc create mode 100644 apps/store/.gitignore create mode 100644 apps/store/README.md create mode 100644 apps/store/catalog.py create mode 100644 apps/store/fulfillment.py create mode 100644 apps/store/payments.py create mode 100644 apps/store/products/claude-skills/changelog-craft/SKILL.md create mode 100644 apps/store/products/claude-skills/changelog-craft/scripts/group_commits.py create mode 100644 apps/store/products/claude-skills/pr-review-pro/SKILL.md create mode 100644 apps/store/products/claude-skills/pr-review-pro/scripts/diffstat.py create mode 100644 apps/store/products/claude-skills/test-forge/SKILL.md create mode 100644 apps/store/products/claude-skills/test-forge/scripts/surface.py create mode 100644 apps/store/products/codex-devkit/AGENTS.md create mode 100644 apps/store/products/codex-devkit/README.md create mode 100644 apps/store/products/codex-devkit/config.example.toml create mode 100644 apps/store/products/codex-devkit/server.py create mode 100644 apps/store/server.py create mode 100644 apps/store/setup_stripe.py create mode 100644 apps/store/tests/test_store.py diff --git a/apps/platform/.gitignore b/apps/platform/.gitignore index 6a1d4a7..f818582 100644 --- a/apps/platform/.gitignore +++ b/apps/platform/.gitignore @@ -1,2 +1,4 @@ crm.db *.db +__pycache__/ +*.pyc diff --git a/apps/platform/__pycache__/billing.cpython-311.pyc b/apps/platform/__pycache__/billing.cpython-311.pyc deleted file mode 100644 index 8469f4c5a11ac4097148c6b7c1c45371200f295f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4735 zcmbVPU2NOd6}}WHiV`i^@_&=YX6mlCmRgFP#*OVJnd`}I*48yFwL|T?3Qf{BZHiPc z$+!{Z&M5lO78%+F8QiT6=9gtDl7~EOfFJrWV9l^cDi9EXK!9LFANr=EeF*Zjb1BKT z@`Dw-A`kD+x#ymH?z!iD`b#(*LQpvU*y>Flq5qIZy|C2^^@C0!w1s3ehh$3jX;juX z=c7oS*62Cf`!aKk_w~=Q-ZwDED$FWZ7quO%*Y``>H1_XD@21B#^C8a zx}<0VR%8_`l4b5!XJ+=yWHOUZPQ2zx7%*}c*hkc2f~X71d#Y&>`_#-h1Ljf$Ro*>{ zv?69bMz1Ha{XCDw^m||>(|6Z zMRPggT1rNhT~^E*wr;tcs#^*^Cu%MWr)*>0Wfl!XlWLdG`_Mq)rGEH1RJM?{do>ZV zcEbchjn_7XzM`gMKHLIxFazW(^aD_I|43rR$SMijRB$4`4CiAnCd6FMOwl#9O__%) z@+@3Jys#kc(;5X}inpT=pt6Mu5ICQJTD9ON+1J>!sej1Zlm~RIUgX6K3tT}&lVct8fg_tkj)!6^8E$WtPonLUN zAsFpz9Ic+QEL!5iykujn=n`pJbsR?t5&A! zNDDYjnB$LUhD#30z{OaYulw8rj=&_iAv7FOjKW>eGRZj!xC5xmLwu>*1NT5rKVdiv zWD}JGp^wHs{7nT>{spQW?Z4V~b?$nv(=zIe&pXX;JKWoMxsh`4#7^JN^4HHg+)L%) z(BF>TFn3P;b)$GJSvr<$wrh zHZAa5oKdo(s@)HP#zH}uHgrYWcL(-7>mBefxGONXUbgppV7(K3uhC03dwh_M)rZ;fY-|{GaGaP zj;BCx*8oU;-Rc+onp%2b6$%w-t9g&F@W6N;{AGi-$c?U*)m@z*4LRgc12=I_uWUaO zMil5qduXoMvetCl}us=ngZE|NHDstQP_C`%N0uA2TQD9d|fcFY` zVrL-$mS-C06xa`7e;e%k>^ilMsym5wzP>(4pz9#$2w4NxkYVxggbg4Ht9g$t-vsE=0w_6{KA%%uX6E%&Iz~h4 zg?!@%RsaMk*mT*f0(0c=h$BIK4_Ine0US2%1^`lNqNG44>memGAFdsX*A();F(KCF z`qO8UvniKeRxCI4rl{FUlHe*23b;+P09`<$*X43`v_`o{SDuVbURNRz4|l|c#4ssQe?>C zh5#mgJaS=#u)IOti5zearizhtDUx=$bcIF1!{uP-;QpBkbl>r3rDJMATTBi+mdXB~rx?OMhJY!^)Q{#kT%ZTYrUyF^^%7d{6j)BxJAeQMG&b?men|ukYTs zeDuN#CH`;)`9Qz@v>a{S>b%(byY5TfTfG;1uLeKsEk=h+(czC+?`%$99lAc^96Sln zjW?^`V&qgQa_a9B4wr|S8r*bdX~QV^aMTCPye&u4-LTOBu#nXJjQsQin~dN*Gm8@CJ89UR^6E(psG)k zbX8>{?6Foikjcn~l*zcEOlHZ3P^)Az7@!Zrr-8UW!*rXf7cuVLI(&k3M`~TWSF`pP z6%%K7{a*a4UPcyI8zdg%VM0i*@NT$wJ8&Cmk}%_4q5Iqm5PNM+Ifc{EM-Z3!GoWzl z6jkx@6jMQjbR(+IK~L>}?jnBE`;}2})BBZCn^S+vsKcp0ccX2W4s7*Y>~VynH=Zg+ zkC&pyZ%4;(MaPTLlcng%%}ALKUl{xJ)F-E$1A|Uts>r`o;$OPWPv7FF|78~WREbY* za+MJB^W~^eZtuMGtF7sa(@x*Wj<480UTPn|-9B-veWKVtS!$mww|1A?55heR12!du g4g`Ih{z^MV3E+ASgbPx@)JVDoin!wcrk)x93&1w3ng9R* diff --git a/apps/platform/server.py b/apps/platform/server.py index d2f06ff..dbc2b48 100644 --- a/apps/platform/server.py +++ b/apps/platform/server.py @@ -31,6 +31,8 @@ DB_PATH = os.environ.get("ALLERION_CRM_DB", os.path.join(os.path.dirname(__file__), "crm.db")) PIPELINE = ["new", "contacted", "qualified", "won", "lost"] +# Self-serve skills store (apps/store) — buy Claude Skills + the Codex plugin. +STORE_URL = os.environ.get("STORE_URL", "http://127.0.0.1:8088") # --------------------------------------------------------------------------- db @@ -227,7 +229,7 @@ def page(title: str, body: str) -> bytes: {html.escape(title)}
{body}