Files
fastmcp-feedback/tests/unit/test_embeddings.py
T
rsp2k c655949bbb Embed feedback, tool errors, event text and opt-in LLM text for similarity search
EmbeddingSink queues text from each batch on its own bounded queue and
upserts vectors into a new {prefix}embeddings table (pgvector on Postgres,
JSON elsewhere). mw.similar() and mw.similar_feedback() search it.
submit_feedback records a feedback.submitted event; record_llm_call keeps
prompt and completion only with capture_llm_text=True.
2026-09-30 00:22:14 -06:00

849 lines
30 KiB
Python

"""Tests for EmbeddingSink, OpenAIEmbedder and similarity search.
DB tests take the ``db_url`` fixture, so they run on SQLite and, with
FFB_TEST_DATABASE_URL set, on Postgres with pgvector.
"""
import asyncio
import contextlib
import hashlib
import json
import logging
import math
import os
import re
import time
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from fastmcp import Client, FastMCP
from fastmcp.exceptions import ToolError
from sqlalchemy import func, select, text, update
from fastmcp_feedback import add_feedback_tools
from fastmcp_feedback.instrumentation import (
REDACTED,
DatabaseSink,
EmbeddingSink,
EventRecord,
OpenAIEmbedder,
Redactor,
ToolCallRecord,
build_metadata,
extract_texts,
instrument,
)
from fastmcp_feedback.instrumentation.embeddings import cosine_score
DIM = 256
SECRET = "sk-abcdefghijklmnopqrstuvwx"
def token_vector(text_: str, dim: int = DIM) -> list[float]:
"""Bag of hashed words, normalized: texts sharing words are close."""
v = [0.0] * dim
for tok in re.findall(r"[a-z0-9]+", text_.lower()):
h = int.from_bytes(hashlib.sha256(tok.encode()).digest()[:4], "big")
v[h % dim] += 1.0
n = math.sqrt(sum(x * x for x in v)) or 1.0
return [x / n for x in v]
class FakeEmbedder:
def __init__(
self,
dim=DIM,
model="fake-embed",
*,
fail=False,
delay=0.0,
gate=None,
out_dim=None,
):
self.dim = dim
self.model = model
self.fail = fail
self.delay = delay
self.gate = gate
self.out_dim = out_dim or dim
self.calls: list[list[str]] = []
@property
def seen(self) -> list[str]:
return [t for batch in self.calls for t in batch]
async def embed(self, texts):
self.calls.append(list(texts))
if self.gate is not None:
await self.gate.wait()
if self.delay:
await asyncio.sleep(self.delay)
if self.fail:
raise RuntimeError("embedding backend down")
return [token_vector(t, self.out_dim) for t in texts]
@pytest.fixture
async def db(db_url):
made = []
def factory(name="calls", **kwargs):
kwargs.setdefault("create_tables", True)
kwargs.setdefault("embedding_dim", DIM)
sink = DatabaseSink(db_url(name), **kwargs)
made.append(sink)
return sink
yield factory
for sink in made:
await sink.engine.dispose()
def call_record(tool="render", outcome="error", message="render timed out", **kw):
return ToolCallRecord(
tool=tool,
started_at=datetime.now(UTC),
duration_ms=1.0,
ok=outcome == "ok",
outcome=outcome,
mode="meta",
error_type=None if outcome == "ok" else kw.pop("error_type", "TimeoutError"),
error_message=None if outcome == "ok" else message,
**kw,
)
def event(kind, key=None, **attrs):
return EventRecord(kind=kind, occurred_at=datetime.now(UTC), key=key, attrs=attrs)
async def rows(sink, *where):
t = sink.embeddings
if not await sink._has_table(t.name): # created on the first embedding
return []
async with sink.engine.connect() as conn:
result = await conn.execute(select(t).where(*where).order_by(t.c.source_id))
return [dict(r) for r in result.mappings()]
async def settle(mw, emb):
await mw.flush()
await emb.flush()
class TestExtractTexts:
def test_call_errors(self):
ok = call_record(outcome="ok")
err = call_record(message="render timed out", error_type="TimeoutError")
soft = call_record(
tool="find",
outcome="soft_error",
message="status=unknown_target",
error_type="SoftError",
)
items = extract_texts([ok, err, soft])
assert [(i.source_type, i.source_id, i.text) for i in items] == [
("call_error", err.id, "render: TimeoutError: render timed out"),
("call_error", soft.id, "find: SoftError: status=unknown_target"),
]
def test_feedback(self):
ev = event(
"feedback.submitted",
key="bug-7Q2X",
title="Render hangs",
description="the render tool hung on scene a",
type="bug",
)
[item] = extract_texts([ev])
assert (item.source_type, item.source_id, item.text) == (
"feedback",
"bug-7Q2X",
"Render hangs\nthe render tool hung on scene a",
)
def test_event_text_keys(self):
ev = event(
"job.failed",
key="job-1",
error_excerpt="solver diverged",
reason="residual NaN",
nproc=8,
)
quiet = event("job.finished", key="job-2", duration_s=3)
[item] = extract_texts([ev, quiet])
assert (item.source_type, item.source_id, item.text) == (
"event",
ev.id,
"solver diverged\nresidual NaN",
)
[custom] = extract_texts([quiet], event_text_keys=("duration_s",))
assert custom.text == "3"
def test_llm(self):
ev = event("llm.call", model="m", prompt="why red?", completion="the light")
[item] = extract_texts([ev])
assert (item.source_type, item.source_id, item.text) == (
"llm",
ev.id,
"prompt: why red?\ncompletion: the light",
)
def test_llm_without_text_is_a_plain_event(self):
ev = event("llm.call", model="m", ok=False, error="upstream 529 overloaded")
[item] = extract_texts([ev])
assert (item.source_type, item.text) == ("event", "upstream 529 overloaded")
def test_sources_filter(self):
records = [
call_record(),
event("feedback.submitted", key="1", title="t", description="d"),
event("job.failed", error="boom"),
event("llm.call", prompt="p"),
]
assert {i.source_type for i in extract_texts(records)} == {
"call_error",
"feedback",
"event",
"llm",
}
assert [
i.source_type for i in extract_texts(records, sources=["feedback"])
] == ["feedback"]
class TestEmbeddingSink:
async def test_every_source_lands(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
err = call_record()
records = [
err,
event(
"feedback.submitted",
key="42",
title="Render hangs",
description="scene a",
),
event("job.failed", key="job-1", error_excerpt="solver diverged"),
event("llm.call", prompt="hello", completion="hi"),
]
await emb.write(records)
await emb.flush()
stored = await rows(sink)
assert {r["source_type"] for r in stored} == {
"call_error",
"feedback",
"event",
"llm",
}
for r in stored:
assert r["dim"] == DIM and r["model"] == "fake-embed"
assert len(r["embedding"]) == DIM
assert r["text_hash"] == hashlib.sha256(r["text"].encode()).hexdigest()
assert r["created_at"] is not None
assert emb.embedded == 4 and emb.errors == 0
await emb.aclose()
async def test_submit_feedback_is_embedded(self, db, tmp_path):
app = FastMCP("app")
sink = db("fb")
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
mw = instrument(app, [sink, emb])
add_feedback_tools(
app, database_url=f"sqlite:///{tmp_path}/fb.db", instrumentation=mw
)
async with Client(app) as client:
result = await client.call_tool(
"submit_feedback",
{
"request": {
"type": "bug",
"title": "Render hangs",
"description": "the render tool hung on scene a",
"submitter": "model",
}
},
)
ref = result.data["feedback_id"]
await settle(mw, emb)
[row] = await rows(sink, sink.embeddings.c.source_type == "feedback")
assert row["source_id"] == ref
assert row["text"] == "Render hangs\nthe render tool hung on scene a"
[ev] = await mw.events_for(ref, kinds=["feedback.submitted"])
assert ev["attrs"]["type"] == "bug" and ev["attrs"]["submitter"] == "model"
await mw.aclose()
async def test_redaction(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(
fake, sink, redactor=Redactor(extra_patterns=[r"acme_\w{8}"])
)
# Straight to the sink, so upstream redaction cannot hide a gap here.
await emb.write(
[
call_record(message=f"401 with api_key={SECRET} and acme_12345678"),
event("job.failed", error=f"Bearer {SECRET}"),
]
)
await emb.flush()
assert fake.seen and all(
SECRET not in t and "acme_12345678" not in t for t in fake.seen
)
stored = await rows(sink)
assert len(stored) == 2
for r in stored:
assert SECRET not in r["text"] and REDACTED in r["text"]
await emb.aclose()
async def test_instrument_hands_over_its_redactor(self, db):
app = FastMCP("app")
sink = db()
emb = EmbeddingSink(FakeEmbedder(), sink)
redactor = Redactor(extra_patterns=[r"acme_\w{8}"])
mw = instrument(app, [sink, emb], redactor=redactor)
assert emb.redactor is redactor
await mw.aclose()
async def test_max_chars(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink, max_chars=50)
await emb.write([event("job.failed", error="word " * 100)])
await emb.flush()
[row] = await rows(sink)
assert len(row["text"]) == 50 and fake.seen == [row["text"]]
await emb.aclose()
async def test_dedupe_and_replace(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
ev = event("feedback.submitted", key="7", title="Render hangs", description="a")
await emb.write([ev])
await emb.flush()
await emb.write([ev, ev])
await emb.flush()
assert fake.seen == ["Render hangs\na"]
assert emb.skipped == 2
changed = event(
"feedback.submitted", key="7", title="Render hangs", description="b"
)
await emb.write([changed])
await emb.flush()
[row] = await rows(sink)
assert row["text"] == "Render hangs\nb"
assert fake.seen == ["Render hangs\na", "Render hangs\nb"]
# Another model's vectors are kept apart.
other = EmbeddingSink(FakeEmbedder(model="other"), sink)
await other.write([changed])
await other.flush()
assert len(await rows(sink)) == 2
await emb.aclose()
await other.aclose()
async def test_queue_full_drops(self, db):
sink = db()
gate = asyncio.Event()
fake = FakeEmbedder(gate=gate)
emb = EmbeddingSink(fake, sink, max_queue=2, batch_size=1)
start = time.perf_counter()
await emb.write([event("job.failed", error=f"boom {i}") for i in range(6)])
assert time.perf_counter() - start < 0.5
await asyncio.sleep(0.05) # the worker takes one and blocks on the gate
await emb.write([event("job.failed", error=f"late {i}") for i in range(3)])
assert emb.dropped >= 4
gate.set()
await emb.flush()
assert emb.errors == 0
assert len(await rows(sink)) == 9 - emb.dropped
await emb.aclose()
async def test_embedder_errors_never_reach_tool_calls(self, db):
app = FastMCP("app")
@app.tool
def render(scene: str) -> str:
raise RuntimeError(f"render of {scene} timed out")
@app.tool
def ping() -> str:
return "pong"
sink = db()
emb = EmbeddingSink(FakeEmbedder(fail=True), sink)
mw = instrument(app, [sink, emb])
async with Client(app) as client:
with contextlib.suppress(ToolError):
await client.call_tool("render", {"scene": "a"})
assert (await client.call_tool("ping", {})).data == "pong"
await settle(mw, emb)
assert emb.errors == 1
assert mw.dispatcher.sink_errors == 0
assert await rows(sink) == []
async with sink.engine.connect() as conn:
calls = (
await conn.execute(select(func.count()).select_from(sink.table))
).scalar_one()
assert calls == 2
assert await mw.similar("render timed out") == []
await mw.aclose()
async def test_slow_embedder_stays_off_the_call_path(self, db):
app = FastMCP("app")
@app.tool
def render() -> str:
raise RuntimeError("render timed out")
sink = db()
emb = EmbeddingSink(FakeEmbedder(delay=2.0), sink, timeout=5.0)
mw = instrument(app, [sink, emb], sink_timeout=0.5)
async with Client(app) as client:
start = time.perf_counter()
for _ in range(3):
with contextlib.suppress(ToolError):
await client.call_tool("render", {})
elapsed = time.perf_counter() - start
await mw.flush()
assert elapsed < 1.5
assert mw.dispatcher.sink_errors == 0
await emb.flush()
assert emb.embedded == 3
await mw.aclose()
async def test_wrong_vector_length_is_an_error(self, db):
sink = db()
emb = EmbeddingSink(FakeEmbedder(out_dim=DIM + 1), sink)
await emb.write([event("job.failed", error="boom")])
await emb.flush()
assert emb.errors == 1 and await rows(sink) == []
await emb.aclose()
async def test_dim_mismatch_refused_at_construction(self, db):
with pytest.raises(ValueError, match="embedding_dim=512"):
EmbeddingSink(FakeEmbedder(dim=512), db())
async def test_unknown_source_refused(self, db):
with pytest.raises(ValueError, match="unknown sources"):
EmbeddingSink(FakeEmbedder(), db(), sources=["feedback", "chat"])
async def test_aclose_drains(self, db):
sink = db()
emb = EmbeddingSink(FakeEmbedder(delay=0.1), sink, batch_size=1)
await emb.write([event("job.failed", error=f"e{i}") for i in range(3)])
await emb.aclose()
assert emb.embedded == 3 and emb.dropped == 0
async def test_aclose_timeout_drops(self, db):
sink = db()
emb = EmbeddingSink(
FakeEmbedder(gate=asyncio.Event()), sink, batch_size=1, timeout=0.2
)
await emb.write([event("job.failed", error=f"e{i}") for i in range(3)])
await emb.aclose()
assert emb.dropped == 2 and emb.embedded == 0
class TestLlmText:
def app(self, sinks, **kwargs):
app = FastMCP("llm")
holder = {}
@app.tool
async def ask(q: str) -> str:
holder["mw"].record_llm_call(
model="m",
prompt=f"{q} token={SECRET}",
completion="because",
attrs={"prompt": "smuggled", "temperature": 0.2},
)
return "because"
holder["mw"] = mw = instrument(app, sinks, **kwargs)
return app, mw
async def test_dropped_by_default(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
app, mw = self.app([sink, emb])
async with Client(app) as client:
await client.call_tool("ask", {"q": "why is the sky blue"})
await settle(mw, emb)
[ev] = [e for e in mw.recent_events if e.kind == "llm.call"]
assert "prompt" not in ev.attrs and "completion" not in ev.attrs
assert ev.attrs["temperature"] == 0.2
assert fake.seen == []
assert await rows(sink) == []
await mw.aclose()
async def test_embedded_when_captured(self, db):
sink = db()
fake = FakeEmbedder()
emb = EmbeddingSink(fake, sink)
app, mw = self.app([sink, emb], capture_llm_text=True, max_error_chars=10)
async with Client(app) as client:
await client.call_tool("ask", {"q": "why is the sky blue " * 10})
await settle(mw, emb)
[ev] = [e for e in mw.recent_events if e.kind == "llm.call"]
assert len(ev.attrs["prompt"]) == 80 # 8 * max_error_chars
assert ev.attrs["completion"] == "because"
[row] = await rows(sink)
assert row["source_type"] == "llm" and row["source_id"] == ev.id
assert row["text"].startswith("prompt: why is the sky blue")
assert row["text"].endswith("completion: because")
await mw.aclose()
async def test_captured_text_is_redacted(self, db):
sink = db()
emb = EmbeddingSink(FakeEmbedder(), sink)
app, mw = self.app([sink, emb], capture_llm_text=True)
async with Client(app) as client:
await client.call_tool("ask", {"q": "q"})
await settle(mw, emb)
[ev] = [e for e in mw.recent_events if e.kind == "llm.call"]
assert SECRET not in ev.attrs["prompt"] and REDACTED in ev.attrs["prompt"]
[row] = await rows(sink)
assert SECRET not in row["text"]
await mw.aclose()
FIXTURE = [
("call_error", "c1", "render: TimeoutError: render timed out after 30 s"),
("call_error", "c2", "upload: OSError: disk full on volume data"),
("event", "e1", "render job timed out waiting for the gpu"),
("feedback", "bug-1", "Render hangs\nthe render tool timed out on scene a"),
("feedback", "bug-2", "Dark mode please\nthe ui is too bright at night"),
("llm", "l1", "prompt: summarise the log\ncompletion: nothing notable"),
]
async def seed(sink, emb):
from fastmcp_feedback.instrumentation import EmbeddingText
for source_type, source_id, text_ in FIXTURE:
emb._enqueue(EmbeddingText(source_type, source_id, text_))
await emb.flush()
assert len(await rows(sink)) == len(FIXTURE)
class TestSimilar:
async def test_ranking_matches_brute_force(self, db, db_backend):
sink = db()
app = FastMCP("s")
emb = EmbeddingSink(FakeEmbedder(), sink)
mw = instrument(app, [sink, emb])
await seed(sink, emb)
query = "render timed out"
q = token_vector(query)
expected = sorted(
((cosine_score(q, token_vector(t)), sid) for _, sid, t in FIXTURE),
reverse=True,
)
found = await mw.similar(query, k=len(FIXTURE))
# Rows sharing no word with the query all score 0; their order is free.
positive = [sid for score, sid in expected if score > 1e-6]
assert len(positive) >= 3
assert [r["source_id"] for r in found][: len(positive)] == positive
assert {r["source_id"] for r in found} == {sid for _, sid in expected}
want = {sid: score for score, sid in expected}
for r in found:
assert r["score"] == pytest.approx(want[r["source_id"]], abs=1e-4)
assert 0.0 <= r["score"] <= 1.0
assert r["created_at"].tzinfo is not None
assert set(r) == {"source_type", "source_id", "text", "score", "created_at"}
assert found[0]["source_id"] == "c1"
top = await mw.similar(query, k=2)
assert [r["source_id"] for r in top] == positive[:2]
only = await mw.similar(query, source_types=["feedback"])
assert [r["source_id"] for r in only] == ["bug-1", "bug-2"]
strong = await mw.similar(query, min_score=0.4)
assert strong and all(r["score"] >= 0.4 for r in strong)
assert len(strong) < len(FIXTURE)
await mw.aclose()
async def test_similar_feedback_excludes_itself(self, db):
sink = db()
app = FastMCP("s")
emb = EmbeddingSink(FakeEmbedder(), sink)
mw = instrument(app, [sink, emb])
await seed(sink, emb)
found = await mw.similar_feedback("bug-1", k=3)
assert "bug-1" not in [r["source_id"] for r in found]
assert found[0]["source_id"] in ("c1", "e1")
feedback_only = await mw.similar_feedback("bug-1", source_types=["feedback"])
assert [r["source_id"] for r in feedback_only] == ["bug-2"]
assert await mw.similar_feedback("bug-404") == []
await mw.aclose()
async def test_scan_limit_without_pgvector(self, db, db_backend, caplog):
if db_backend == "postgres":
pytest.skip("brute force only runs without pgvector")
sink = db()
app = FastMCP("s")
emb = EmbeddingSink(FakeEmbedder(), sink, scan_limit=2)
mw = instrument(app, [sink, emb])
await seed(sink, emb)
caplog.set_level(logging.INFO, "fastmcp_feedback.instrumentation.embeddings")
assert len(await mw.similar("render", k=10)) <= 2
await mw.similar("render", k=10)
assert len([r for r in caplog.records if "scan_limit" in r.getMessage()]) == 1
await mw.aclose()
async def test_never_raises(self, db):
app = FastMCP("s")
mw = instrument(app, [])
assert await mw.similar("x") == []
assert await mw.similar_feedback("1") == []
await mw.aclose()
sink = db()
emb = EmbeddingSink(FakeEmbedder(fail=True), sink)
mw = instrument(FastMCP("t"), [sink, emb])
assert await mw.similar("x") == []
# No table yet: the lookup itself fails.
assert await mw.similar_feedback("1") == []
await mw.aclose()
class TestPrune:
async def test_keeps_feedback_embeddings(self, db):
sink = db(prune_batch=2)
emb = EmbeddingSink(FakeEmbedder(), sink)
await seed(sink, emb)
t = sink.embeddings
old = datetime.now(UTC) - timedelta(days=40)
async with sink.engine.begin() as conn:
await conn.execute(
update(t).where(t.c.source_id != "c2").values(created_at=old)
)
deleted = await sink.prune(older_than=datetime.now(UTC) - timedelta(days=30))
assert deleted == 3
left = sorted(r["source_id"] for r in await rows(sink))
assert left == ["bug-1", "bug-2", "c2"]
await emb.aclose()
async def test_prune_without_embeddings_table(self, db):
sink = db()
await sink.write([call_record()])
assert not await sink._has_table(sink.embeddings.name)
assert await sink.prune(older_than=datetime.now(UTC) + timedelta(days=1)) == 1
class TestCreateTables:
async def test_embeddings_table_only_with_an_embedding_sink(self, db):
sink = db()
await sink.write([call_record()])
assert not await sink._has_table(sink.embeddings.name)
emb = EmbeddingSink(FakeEmbedder(), sink)
await emb.write([call_record()])
await emb.flush()
assert await sink._has_table(sink.embeddings.name)
await emb.aclose()
async def test_create_tables_false_creates_nothing(self, db):
sink = db(create_tables=False)
assert await sink.ensure_embeddings() is True
assert not await sink._has_table(sink.embeddings.name)
async def test_failure_is_logged_not_raised(self, db, monkeypatch, caplog):
sink = db()
async def boom():
raise RuntimeError("no room")
monkeypatch.setattr(sink, "_create_embeddings_table", boom)
caplog.set_level(logging.WARNING)
emb = EmbeddingSink(FakeEmbedder(), sink)
assert await sink.ensure_embeddings() is False
assert any("Could not create" in r.getMessage() for r in caplog.records)
await emb.write([call_record()])
await emb.flush()
assert emb.errors == 1 # the insert fails; counted, not raised
await emb.aclose()
@pytest.mark.postgres
async def test_postgres_extension_table_and_index(self, db, db_backend):
if db_backend != "postgres":
pytest.skip("Postgres only")
sink = db()
await sink._ready()
async with sink.engine.begin() as conn:
await conn.execute(text("DROP EXTENSION IF EXISTS vector"))
assert await sink.ensure_embeddings() is True
async with sink.engine.connect() as conn:
ext = (
await conn.execute(
text("SELECT count(*) FROM pg_extension WHERE extname = 'vector'")
)
).scalar_one()
index = (
await conn.execute(
text(
"SELECT indexdef FROM pg_indexes "
"WHERE indexname = 'ffb_embeddings_hnsw'"
)
)
).scalar_one()
coltype = (
await conn.execute(
text(
"SELECT format_type(atttypid, atttypmod) FROM pg_attribute "
"WHERE attrelid = 'ffb_embeddings'::regclass "
"AND attname = 'embedding'"
)
)
).scalar_one()
assert ext == 1
assert "hnsw" in index and "vector_cosine_ops" in index
assert coltype == f"vector({DIM})"
@pytest.mark.postgres
async def test_postgres_without_privilege(self, db, db_backend, db_url, caplog):
if db_backend != "postgres":
pytest.skip("Postgres only")
from sqlalchemy.engine import make_url
admin = db("priv", embedding_dim=DIM)
role = f"ffb_limited_{uuid.uuid4().hex[:8]}"
password = uuid.uuid4().hex
url = make_url(db_url("priv"))
async with admin.engine.begin() as conn:
await conn.execute(text("DROP EXTENSION IF EXISTS vector"))
await conn.execute(text(f"CREATE ROLE {role} LOGIN PASSWORD '{password}'"))
await conn.execute(text(f"GRANT ALL ON SCHEMA public TO {role}"))
limited = DatabaseSink(
url.set(username=role, password=password).render_as_string(
hide_password=False
),
create_tables=True,
embedding_dim=DIM,
)
try:
caplog.set_level(logging.WARNING)
emb = EmbeddingSink(FakeEmbedder(), limited)
assert await limited.ensure_embeddings() is False
messages = [r.getMessage() for r in caplog.records]
assert any("CREATE EXTENSION vector failed" in m for m in messages)
assert any("Could not create" in m for m in messages)
await limited.write([call_record()]) # the rest still works
await emb.write([call_record()])
await emb.flush()
assert emb.errors == 1
await emb.aclose()
finally:
await limited.engine.dispose()
async with admin.engine.begin() as conn:
await conn.execute(text(f"DROP OWNED BY {role}"))
await conn.execute(text(f"DROP ROLE {role}"))
class TestMetadata:
def test_build_metadata_tables(self):
md = build_metadata("x_", embedding_dim=768)
t = md.tables["x_embeddings"]
assert t.c.embedding.type.dim == 768
assert {c.name for c in t.c} == {
"id",
"source_type",
"source_id",
"model",
"dim",
"text_hash",
"text",
"created_at",
"embedding",
}
without = build_metadata("y_", embedding_dim=None)
assert "y_embeddings" not in without.tables
assert "y_tool_calls" in without.tables
class TestOpenAIEmbedder:
def transport(self, handler):
from fastmcp_feedback.instrumentation.embeddings import _http_module
return _http_module().MockTransport(handler)
async def test_request_batches_and_order(self):
seen = []
def handler(request):
body = json.loads(request.content)
seen.append((request.url.path, request.headers.get("authorization"), body))
data = [
{"index": i, "embedding": [float(len(t)), 0.0, 1.0]}
for i, t in enumerate(body["input"])
]
from fastmcp_feedback.instrumentation.embeddings import _http_module
return _http_module().Response(200, json={"data": list(reversed(data))})
e = OpenAIEmbedder(
"https://gw.example/v1/",
api_key="k",
model="m",
dim=3,
batch_size=2,
transport=self.transport(handler),
)
out = await e.embed(["a", "bb", "ccc"])
assert out == [[1.0, 0.0, 1.0], [2.0, 0.0, 1.0], [3.0, 0.0, 1.0]]
assert [s[0] for s in seen] == ["/v1/embeddings", "/v1/embeddings"]
assert seen[0][1] == "Bearer k"
assert [s[2]["input"] for s in seen] == [["a", "bb"], ["ccc"]]
assert all(s[2]["model"] == "m" for s in seen)
assert await e.embed([]) == []
async def test_dim_mismatch_raises(self):
from fastmcp_feedback.instrumentation.embeddings import _http_module
def handler(request):
return _http_module().Response(
200, json={"data": [{"index": 0, "embedding": [0.1, 0.2]}]}
)
e = OpenAIEmbedder(
"https://gw.example/v1", dim=3, transport=self.transport(handler)
)
with pytest.raises(ValueError, match="2-dim vector, expected 3"):
await e.embed(["a"])
async def test_http_error_raises(self):
from fastmcp_feedback.instrumentation.embeddings import _http_module
e = OpenAIEmbedder(
"https://gw.example/v1",
dim=3,
transport=self.transport(
lambda r: _http_module().Response(403, json={"error": "nope"})
),
)
with pytest.raises(Exception, match="403"):
await e.embed(["a"])
LIVE_URL = os.environ.get("FFB_TEST_EMBED_URL")
@pytest.mark.skipif(not LIVE_URL, reason="FFB_TEST_EMBED_URL is not set")
async def test_live_gateway():
"""FFB_TEST_EMBED_URL=https://.../v1 FFB_TEST_EMBED_KEY=... [FFB_TEST_EMBED_MODEL]."""
e = OpenAIEmbedder(
LIVE_URL,
api_key=os.environ.get("FFB_TEST_EMBED_KEY"),
model=os.environ.get("FFB_TEST_EMBED_MODEL", "mxbai-embed-large"),
dim=int(os.environ.get("FFB_TEST_EMBED_DIM", "1024")),
)
a, b, c = await e.embed(
["render timed out", "the render took too long and timed out", "dark mode"]
)
assert len(a) == e.dim
assert cosine_score(a, b) > cosine_score(a, c)