typescript-embedding-provider-fallback
The failure chain was three silent failure modes stacking on one dead code path. Full implementation: /tmp/embed-fix/embeddings.ts, verified by /tmp/embed-fix/embeddings.test.ts (18/18 passing, run with node --experimental-strip-types --test).
createAutoProviders() — return ALL healthy providers (was: first only)A probe that passes is not proof an embed will succeed (LM Studio /v1/models 200s even when the embedding model is unloaded → /v1/embeddings 400s). Returning only the first healthy provider left nothing to fall back to.
export async function createAutoProviders(cfg: ProviderConfig): Promise<EmbedProvider[]> {
const healthy: EmbedProvider[] = [];
for (const Ctor of EMBEDDING_PROVIDERS) {
const p = new Ctor(cfg);
try {
if (await p.healthy()) healthy.push(p); // was: return [p]; (only first!)
} catch { /* probe threw -> not healthy, keep probing */ }
}
return healthy; // ALL healthy -> recall can fall back
}
recall() — iterate with fallback on embed failure AND empty vector[] is truthy in JS, so if (result.vector) accepted Ollama's 200-with-embedding: [] for a missing model as a valid embed — recall silently returned nothing.
export async function recall(query, providers, store, k = 5) {
let lastErr: unknown = new Error('no embedding providers available');
for (const p of providers) { // iterate, don't stop at first
try {
const r = await p.embed(query);
if (r.isError) throw new EmbedError(`${p.name}: ${r.error ?? 'embed error'}`);
if (r.vector.length === 0) // explicit empty check, NOT truthiness
throw new EmbedError(`${p.name} returned an empty vector`);
return await store.search(r.vector, k);
} catch (e) { lastErr = e; } // fall through to next provider
}
throw lastErr; // never return silently-empty results
}
wrapHandler() — flag error results with isError: trueError-shaped results ({error: "model_not_loaded"}) previously passed through unflagged, indistinguishable from success.
export function wrapHandler(fn: Handler): Handler {
return async () => {
const result = await fn();
if (result && typeof result === 'object' && 'error' in result && result.error) {
(result as { isError?: boolean }).isError = true; // was: returned as-is
}
return result;
};
}
cosineSimilarity() — throw on zero-length instead of returning NaNexport function cosineSimilarity(a: number[], b: number[]): number {
if (a.length === 0) throw new EmbedError('cosineSimilarity: zero-length vector a');
if (b.length === 0) throw new EmbedError('cosineSimilarity: zero-length vector b');
if (a.length !== b.length) throw new EmbedError('cosineSimilarity: dimension mismatch');
let dot = 0, na = 0, nb = 0;
for (let i = 0; i < a.length; i++) { dot += a[i] * b[i]; na += a[i] * a[i]; nb += b[i] * b[i]; }
const mag = Math.sqrt(na) * Math.sqrt(nb);
if (mag === 0) throw new EmbedError('cosineSimilarity: zero-magnitude vector');
return dot / mag;
}
lmstudio, auto path never ranThe schema/docs claimed auto fallback, but the hardcoded default 'lmstudio' forced explicit mode (single provider, no fallback) for every user who didn't set the field — fixes 1–4 never engaged. Default fixed to 'auto'; explicit modes intentionally stay single-provider.
export const configSchema = {
provider: {
type: 'string',
enum: ['auto', 'lmstudio', 'ollama', 'openai'],
default: 'auto', // WAS: 'lmstudio' — the dead path
},
} as const;
export function resolveProviders(cfg: ProviderConfig): ProviderMode[] {
const mode: ProviderMode = cfg.provider ?? 'auto';
if (mode === 'auto') return ['lmstudio', 'ollama']; // expansion, fallback available
return [mode]; // explicit = no fallback (by design)
}
Ran the real test suite against mocked transports (LM Studio 200-probe/400-embed, Ollama 200-empty-vector, thrown probes) — **18/18 passing**:
```
ok 1 - createAutoProviders returns ALL healthy providers, not just the first
ok 2 - createAutoProviders skips providers whose probe throws
ok 3 - recall falls back when lmstudio embed 400s (probe passed)
ok 4 - recall falls back when provider returns 200 + empty vector
ok 5 - recall throws when ALL providers fail
ok 6 - wrapHandler sets isError:true when result has error
ok 7 - wrapHandler leaves successful results unflagged
ok 8 - recall honors wrapHandler isError flag
ok 9 - cosineSimilarity throws on zero-length vectors
ok 10 - cosineSimilarity throws on dimension mismatch and zero magnitude
ok 11 - cosineSimilarity returns correct values for valid vectors
ok 12 - config default is auto (was hardcoded lmstudio)
ok 13 - resolveProviders: no provider configured => auto expands to all candidates
ok 14 - resolveProviders: explicit provider => no fallback (by design)
ok 15 - resolveProviders: explicit lmstudio does NOT silently include ollama
ok 16 - E2E: probe-passing-but-broken lmstudio + empty ollama now surface a real result
ok 17 - [] is truthy in JS — the old check `if (result.vector)` was broken
ok 18 - config default matches docs claim (auto)
# tests 18
# pass 18
# fail 0
```
Edge cases covered: probe passing but embed 400 (LM Studio unloaded model); 200 with `embedding: []` (Ollama truthiness trap); all-providers-fail must throw, never return empty silently; thrown probe is skipped, not fatal; wrapped `{error}` results trigger fallback in `recall`; zero-length/mismatched/zero-magnitude vectors throw; explicit `provider: 'ollama'` still means no fallback; unset config now expands to both providers via `auto`.{"model": "deepseek-v4-flash", "problem_class": "typescript-embedding-provider-fallback", "result": "passed", "tests": 18}