Files
proffesor-for-testing__agen…/tests/integration/embedder-endpoint-llamacpp.test.ts
Dragan Spiridonov a9f7082f15 fix(embeddings): harden ADR-097 endpoint client against devil's-advocate audit
Closes 11 findings from a post-merge audit of the external embedder endpoint
implementation. The "no silent hash fallback" correctness promise was being
contradicted by two callers; the connect timeout was actually an idle timeout;
the identity probe could be bypassed in several paths; and identity drift
across runs was undetectable. All hardened.

Critical/High fixes:
- sqlite-persistence backfill no longer falls back to hash embeddings when the
  endpoint is configured; re-raises to protect the HNSW index from non-comparable
  vectors (was poisoning silently on every endpoint blip).
- qe-reasoning-bank.embed() propagates the throw under isUsingEndpoint() instead
  of silently substituting a hash vector.
- EmbedderEndpointClient.embed() now gates on probe() — lazy probe on first use
  with in-flight dedup so 20+ concurrent embeds share one probe.
- Circuit breaker recovery (window expires) clears cached identity so the next
  embed re-probes — endpoint restarts often coincide with model swaps.
- True TCP-connect timeout enforced at the socket layer (was req.setTimeout,
  which is an idle-after-write timeout — SYN blackholes hung ~75s instead of 5s).
- Endpoint identity persisted to memory.db kv_store (_system namespace) — cross-
  run fingerprint drift now surfaces as a loud warning on next boot.

Medium fixes:
- encoding_format: 'float' pinned in request; base64 responses fail loud.
- TLS knobs (ca, cert, key, rejectUnauthorized, servername) passthrough for
  self-hosted https endpoints without NODE_TLS_REJECT_UNAUTHORIZED=0.
- URL userinfo stripped + warned at parse; redacted from all error messages
  and logs.
- Embedding cache key namespaced by mode (endpoint:<fingerprint>:<text> vs
  inproc:<text>) — cross-mode collisions structurally impossible.

Tests:
- 33 unit tests pass (was 23): adds probe-gate, breaker re-probe, connect
  timeout, DNS failure, concurrent embed dedup, base64 fail-loud, URL
  redaction, encoding_format wire format.
- 6 live integration tests against real llama-server (all-MiniLM-L6-v2.Q8_0.gguf):
  probe, single embed, batch, lazy probe gate, dim mismatch, 50-input batch.
  Replaces the prior "9ms in-process fake" with honest numbers (cold 15ms,
  warm 2ms against localhost llama.cpp).
- New tests/integration/embedder-endpoint-llamacpp.test.ts is gated on
  AQE_LLAMA_EMBED_URL so it skips cleanly in CI without the binary.

ADR-097 updated with the per-caller audit table (12 writers verified
non-poisoning) and "drop-in for 6 providers" claim softened to "verified
against llama-server; OpenAI shape compat expected but unverified" per
provider.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-20 07:52:23 +00:00

126 lines
4.8 KiB
TypeScript

/**
* Real-provider integration test for ADR-097 endpoint client.
*
* Drives EmbedderEndpointClient against a live `llama-server --embeddings`
* instance (llama.cpp). This is the test the devil's-advocate audit demanded
* because the unit tests only talk to an in-process loopback fake — they prove
* the client talks to itself, not that the wire format actually works against
* a real provider.
*
* Set AQE_LLAMA_EMBED_URL=http://127.0.0.1:18080 to point at your running
* server. When the env var is unset the suite skips, so this test does not
* become a CI-time flake when the binary or model isn't present.
*
* To run locally:
* /tmp/llama.cpp/build/bin/llama-server -m /tmp/models/all-MiniLM-L6-v2.Q8_0.gguf \
* --port 18080 --host 127.0.0.1 --embeddings --pooling mean -c 512
* AQE_LLAMA_EMBED_URL=http://127.0.0.1:18080 \
* npx vitest run tests/integration/embedder-endpoint-llamacpp.test.ts
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { EmbedderEndpointClient } from '../../src/learning/embedder-endpoint-client.js';
const ENDPOINT = process.env.AQE_LLAMA_EMBED_URL;
const describeIfLive = ENDPOINT ? describe : describe.skip;
describeIfLive('ADR-097 — real llama-server provider', () => {
// Construction is gated behind beforeAll so the describe.skip path doesn't
// try to parse an undefined endpoint URL at module-eval time.
let client: EmbedderEndpointClient;
beforeAll(() => {
client = new EmbedderEndpointClient({
endpoint: ENDPOINT as string,
model: 'all-MiniLM-L6-v2',
expectedDim: 384,
connectTimeoutMs: 5_000,
requestTimeoutMs: 30_000,
});
});
afterAll(() => {
client?.close();
});
it('probe returns dim=384 and a stable fingerprint', async () => {
const id1 = await client.probe();
expect(id1.dim).toBe(384);
expect(id1.fingerprint).toMatch(/^[0-9a-f]{16}$/);
// Second probe must reproduce the same fingerprint against the same model
// & quantization. This is what makes drift detection meaningful.
const id2 = await client.probe();
expect(id2.fingerprint).toBe(id1.fingerprint);
});
it('embed single text returns a unit-length 384-d vector', async () => {
const [vec] = await client.embed(['the quick brown fox jumps over the lazy dog']);
expect(vec).toHaveLength(384);
const norm = Math.sqrt(vec.reduce((s, x) => s + x * x, 0));
expect(norm).toBeCloseTo(1, 4);
});
it('embed batch preserves order and produces semantically meaningful vectors', async () => {
const inputs = [
'cats are mammals',
'dogs are mammals',
'sql is a query language',
];
const vecs = await client.embed(inputs);
expect(vecs).toHaveLength(3);
vecs.forEach((v) => expect(v).toHaveLength(384));
// Semantic check: cats~dogs (both mammals) should be closer than cats~sql.
// This is the practical reason we need a real model — fakeEmbedding can't
// give us this signal.
const cos = (a: number[], b: number[]) => a.reduce((s, x, i) => s + x * b[i], 0);
const catsDogs = cos(vecs[0], vecs[1]);
const catsSql = cos(vecs[0], vecs[2]);
expect(catsDogs).toBeGreaterThan(catsSql);
});
it('embed() is gated on probe — works on a fresh client without manual probe', async () => {
const fresh = new EmbedderEndpointClient({
endpoint: ENDPOINT as string,
model: 'all-MiniLM-L6-v2',
expectedDim: 384,
});
try {
const [vec] = await fresh.embed(['lazy probe gate']);
expect(vec).toHaveLength(384);
expect(fresh.getCachedIdentity()).not.toBeNull();
} finally {
fresh.close();
}
});
it('rejects with dim mismatch when expectedDim is wrong', async () => {
const wrong = new EmbedderEndpointClient({
endpoint: ENDPOINT as string,
model: 'all-MiniLM-L6-v2',
expectedDim: 768, // MiniLM is 384 — must fail loud
});
try {
await expect(wrong.embed(['x'])).rejects.toThrow(/dim mismatch/);
} finally {
wrong.close();
}
});
it('handles batches larger than the default 32-input chunk', async () => {
// Real llama-server enforces its own batch limits. Verify the wire shape
// holds for 50 inputs in one call — exercises the index-ordering path
// against actual server batching behavior, not just the fake.
const inputs = Array.from({ length: 50 }, (_, i) => `sentence number ${i} for batching test`);
const vecs = await client.embed(inputs);
expect(vecs).toHaveLength(50);
vecs.forEach((v) => {
expect(v).toHaveLength(384);
const norm = Math.sqrt(v.reduce((s, x) => s + x * x, 0));
expect(norm).toBeCloseTo(1, 4);
});
// Each sentence text is distinct, so vectors should be distinct too
const fingerprints = new Set(vecs.map((v) => v.slice(0, 8).join(',')));
expect(fingerprints.size).toBe(50);
});
});