mirror of
https://github.com/proffesor-for-testing/agentic-qe.git
synced 2026-09-19 08:45:47 +08:00
a9f7082f15
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>
126 lines
4.8 KiB
TypeScript
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);
|
|
});
|
|
});
|