Skip to content

Commit bd4b773

Browse files
fatdinheroona-agent
andcommitted
feat: embed sidecar (ONNX) + detect API uses real embeddings
embed/: - Rewrite main.py: onnxruntime + tokenizers, no torch/sentence-transformers - all-MiniLM-L6-v2 qint8 baked into image (22MB), no runtime download - fly.toml: agentsprotocol-embed, fra, 1GB RAM - Deployed: https://agentsprotocol-embed.fly.dev/ detect/: - api.py: _remote_embed() calls embed sidecar, falls back to stub on error - api.py: detail field shows model name (all-MiniLM-L6-v2) - Dockerfile: add httpx wheel - index.html: API_URL hardcoded to agentsprotocol-detect.fly.dev .gitignore: exclude embed/wheels/ and embed/model/ Co-authored-by: Ona <no-reply@ona.com>
1 parent af8f646 commit bd4b773

8 files changed

Lines changed: 118 additions & 57 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,3 +31,5 @@ Thumbs.db
3131
# Fly deploy artifacts
3232
detect/wheels/
3333
detect/agentsprotocol_pkg/
34+
embed/wheels/
35+
embed/model/

detect/Dockerfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ WORKDIR /app
44

55
COPY wheels/ /wheels/
66
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
7-
fastapi uvicorn[standard] pydantic numpy scipy cryptography \
7+
fastapi uvicorn[standard] pydantic numpy scipy cryptography httpx \
88
&& rm -rf /wheels
99

1010
COPY agentsprotocol_pkg ./agentsprotocol

detect/api.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,10 @@
1515
from __future__ import annotations
1616

1717
import math
18+
import os
1819
from typing import Optional
1920

21+
import httpx
2022
from fastapi import FastAPI
2123
from fastapi.middleware.cors import CORSMiddleware
2224
from pydantic import BaseModel
@@ -25,6 +27,18 @@
2527
from agentsprotocol.psi_test import compute_psi, compute_error_vectors
2628
from agentsprotocol.wise_score import compute_wise_score_aggregate, attacker_success_probability
2729

30+
EMBED_URL = os.getenv("EMBED_URL", "https://agentsprotocol-embed.fly.dev/embed")
31+
32+
33+
def _remote_embed(text: str):
34+
"""Call the embed sidecar; fall back to stub on any error."""
35+
try:
36+
r = httpx.post(EMBED_URL, json={"texts": [text]}, timeout=5.0)
37+
r.raise_for_status()
38+
return r.json()["embeddings"][0]
39+
except Exception:
40+
return _stub_embed(text)
41+
2842
app = FastAPI(title="AgentsProtocol Detect API", version="1.0")
2943

3044
app.add_middleware(
@@ -77,7 +91,7 @@ def validate(req: ValidateRequest) -> ValidateResponse:
7791
s = compute_s_con(
7892
claim_text=req.claim,
7993
knowledge_corpus=corpus,
80-
embed=_stub_embed,
94+
embed=_remote_embed,
8195
tau=0.0, # no threshold clipping — return raw score
8296
)
8397

@@ -129,7 +143,7 @@ def validate(req: ValidateRequest) -> ValidateResponse:
129143
unverified = confidence < 0.8
130144

131145
detail = (
132-
f"S_con={s:.3f} (stub embedder), Psi={psi:.3f} ({_N_VALIDATORS} simulated validators), "
146+
f"S_con={s:.3f} (all-MiniLM-L6-v2), Psi={psi:.3f} ({_N_VALIDATORS} simulated validators), "
133147
f"W={w:.3f}. "
134148
+ ("Accepted by protocol." if accepted else
135149
f"Rejected: {'WiseScore below θ_min=0.60' if w < theta_min else 'Ψ below ψ_min=0.70'}.")

detect/index.html

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -526,11 +526,10 @@ <h1>Is this claim <em>independent</em>?</h1>
526526

527527
/* ── API endpoint (detect/api.py via uvicorn) ── */
528528
const API_URL = (()=>{
529-
// Same origin in production; localhost:8000 for local dev
530-
const base = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'
531-
? 'http://localhost:8000'
532-
: window.location.origin;
533-
return base + '/validate';
529+
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
530+
return 'http://localhost:8000/validate';
531+
}
532+
return 'https://agentsprotocol-detect.fly.dev/validate';
534533
})();
535534

536535
async function fetchFromAPI(text, category){

embed/.dockerignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
**/__pycache__
2+
*.pyc
3+
*.pyo
4+
requirements.txt

embed/Dockerfile

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,18 @@ FROM python:3.11-slim
22

33
WORKDIR /app
44

5-
# Install dependencies first (cached layer)
6-
COPY requirements.txt .
7-
RUN pip install --no-cache-dir -r requirements.txt
5+
# Install all deps from bundled wheels — no PyPI access needed
6+
COPY wheels/ /wheels/
7+
RUN pip install --no-cache-dir --no-index --find-links=/wheels \
8+
fastapi "uvicorn[standard]" numpy onnxruntime tokenizers \
9+
&& rm -rf /wheels
810

9-
# Pre-download model so container starts instantly
10-
RUN python -c "from sentence_transformers import SentenceTransformer; SentenceTransformer('all-MiniLM-L6-v2')"
11+
# Bake model into image — no runtime download needed
12+
COPY model/ ./model/
1113

1214
COPY main.py .
1315

14-
ENV EMBED_MODEL=all-MiniLM-L6-v2
16+
ENV MODEL_DIR=/app/model
1517

1618
EXPOSE 8000
1719

embed/fly.toml

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
app = "agentsprotocol-embed"
2+
primary_region = "fra"
3+
4+
[build]
5+
6+
[http_service]
7+
internal_port = 8000
8+
force_https = true
9+
auto_stop_machines = true
10+
auto_start_machines = true
11+
min_machines_running = 0
12+
13+
[[vm]]
14+
memory = "1gb"
15+
cpu_kind = "shared"
16+
cpus = 1

embed/main.py

Lines changed: 67 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,18 @@
1-
"""Embedding sidecar service.
1+
"""Embedding sidecar — ONNX Runtime backend.
22
3-
Provides a single endpoint:
4-
POST /embed {"texts": ["...", "..."]} -> {"embeddings": [[...], [...]]}
5-
6-
Uses sentence-transformers all-MiniLM-L6-v2 (384-dim, ~90MB).
7-
Falls back to the stub embedder (SHA-256 tiling) if the model is unavailable,
8-
so the validator can start without the model downloaded.
3+
Uses all-MiniLM-L6-v2 (quantized, 22MB) via onnxruntime + tokenizers.
4+
No torch, no sentence-transformers, no internet access at runtime.
95
10-
Run:
11-
pip install -r requirements.txt
12-
uvicorn embed.main:app --host 0.0.0.0 --port 8000
6+
Endpoints:
7+
POST /embed {"texts": ["...", "..."]} -> {"embeddings": [[...], [...]]}
8+
GET /health
139
"""
1410
from __future__ import annotations
1511

1612
import hashlib
1713
import logging
1814
import os
15+
from pathlib import Path
1916
from typing import List
2017

2118
import numpy as np
@@ -24,50 +21,77 @@
2421

2522
logger = logging.getLogger(__name__)
2623

27-
app = FastAPI(title="AgentsProtocol Embed Service", version="1.0")
24+
app = FastAPI(title="AgentsProtocol Embed Service", version="2.0")
25+
26+
MODEL_DIR = Path(os.getenv("MODEL_DIR", "/app/model"))
2827

29-
# ---------------------------------------------------------------------------
30-
# Model loading — lazy, with stub fallback
31-
# ---------------------------------------------------------------------------
28+
_session = None
29+
_tokenizer = None
3230

33-
_model = None
34-
_use_stub = False
3531

36-
def _load_model() -> None:
37-
global _model, _use_stub
38-
model_name = os.getenv("EMBED_MODEL", "all-MiniLM-L6-v2")
32+
def _load() -> None:
33+
global _session, _tokenizer
3934
try:
40-
from sentence_transformers import SentenceTransformer
41-
_model = SentenceTransformer(model_name)
42-
logger.info("Loaded embedding model: %s", model_name)
35+
import onnxruntime as ort
36+
from tokenizers import Tokenizer
37+
38+
opts = ort.SessionOptions()
39+
opts.inter_op_num_threads = 1
40+
opts.intra_op_num_threads = 1
41+
_session = ort.InferenceSession(
42+
str(MODEL_DIR / "model.onnx"),
43+
sess_options=opts,
44+
providers=["CPUExecutionProvider"],
45+
)
46+
_tokenizer = Tokenizer.from_file(str(MODEL_DIR / "tokenizer.json"))
47+
_tokenizer.enable_padding(pad_id=0, pad_token="[PAD]", length=128)
48+
_tokenizer.enable_truncation(max_length=128)
49+
logger.info("ONNX model loaded from %s", MODEL_DIR)
4350
except Exception as exc:
44-
logger.warning("Could not load %s (%s) — using stub embedder", model_name, exc)
45-
_use_stub = True
51+
logger.warning("Could not load ONNX model (%s) — using stub", exc)
4652

4753

4854
@app.on_event("startup")
4955
async def startup() -> None:
50-
_load_model()
56+
_load()
5157

5258

53-
# ---------------------------------------------------------------------------
54-
# Stub embedder (mirrors Rust stub_embed / Python _stub_embed exactly)
55-
# ---------------------------------------------------------------------------
59+
def _mean_pool(token_embeddings: np.ndarray, attention_mask: np.ndarray) -> np.ndarray:
60+
mask = attention_mask[..., np.newaxis].astype(float)
61+
summed = (token_embeddings * mask).sum(axis=1)
62+
counts = mask.sum(axis=1).clip(min=1e-9)
63+
pooled = summed / counts
64+
norms = np.linalg.norm(pooled, axis=1, keepdims=True).clip(min=1e-9)
65+
return (pooled / norms).astype(np.float32)
66+
5667

5768
def _stub_embed(text: str) -> List[float]:
5869
DIM = 384
5970
digest = hashlib.sha256(text.encode()).digest()
6071
raw = bytes(digest[i % 32] for i in range(DIM))
6172
vec = np.array([b - 127.5 for b in raw], dtype=np.float64)
6273
norm = np.linalg.norm(vec)
63-
if norm > 0:
64-
vec /= norm
65-
return vec.tolist()
66-
74+
return (vec / norm if norm > 0 else vec).tolist()
75+
76+
77+
def _onnx_embed(texts: List[str]) -> List[List[float]]:
78+
enc = _tokenizer.encode_batch(texts)
79+
input_ids = np.array([e.ids for e in enc], dtype=np.int64)
80+
attention_mask = np.array([e.attention_mask for e in enc], dtype=np.int64)
81+
token_type_ids = np.zeros_like(input_ids)
82+
83+
outputs = _session.run(
84+
None,
85+
{
86+
"input_ids": input_ids,
87+
"attention_mask": attention_mask,
88+
"token_type_ids": token_type_ids,
89+
},
90+
)
91+
# outputs[0] = last_hidden_state (batch, seq, 384)
92+
pooled = _mean_pool(outputs[0], attention_mask)
93+
return pooled.tolist()
6794

68-
# ---------------------------------------------------------------------------
69-
# API
70-
# ---------------------------------------------------------------------------
7195

7296
class EmbedRequest(BaseModel):
7397
texts: List[str]
@@ -80,17 +104,17 @@ class EmbedResponse(BaseModel):
80104

81105
@app.post("/embed", response_model=EmbedResponse)
82106
async def embed(req: EmbedRequest) -> EmbedResponse:
83-
if _use_stub or _model is None:
84-
embeddings = [_stub_embed(t) for t in req.texts]
85-
return EmbedResponse(embeddings=embeddings, model="stub")
86-
87-
vecs = _model.encode(req.texts, normalize_embeddings=True)
107+
if _session is None or _tokenizer is None:
108+
return EmbedResponse(
109+
embeddings=[_stub_embed(t) for t in req.texts],
110+
model="stub",
111+
)
88112
return EmbedResponse(
89-
embeddings=vecs.tolist(),
90-
model=_model.get_sentence_embedding_dimension() and "all-MiniLM-L6-v2",
113+
embeddings=_onnx_embed(req.texts),
114+
model="all-MiniLM-L6-v2-qint8",
91115
)
92116

93117

94118
@app.get("/health")
95119
async def health() -> dict:
96-
return {"status": "ok", "stub": _use_stub}
120+
return {"status": "ok", "model_loaded": _session is not None}

0 commit comments

Comments
 (0)