1
0

3 Commits 332ba839b2 ... 4f1e3deb9b

Autor SHA1 Nachricht Datum
  Claude 4f1e3deb9b merge(qmd): land the stranded 429-abort fix onto the commercial-policy head (i-yghj098h) vor 2 Wochen
  root 34c95700f7 feat(qmd): enforce commercial-api-only model operations vor 2 Wochen
  Claude 89f322244e fix(qmd): stop one 429 from aborting a whole embedding run (i-yghj098h) vor 2 Wochen

+ 5 - 5
dist/cli/qmd.js

@@ -1458,12 +1458,12 @@ function buildProviderOpts(values, providerCliKind) {
             ...(timeoutMs !== undefined ? { timeoutMs } : {}),
         }
         : undefined;
-    // Historical flag is passed through so the factory can reject it as typed HOLD.
-    const autoFallback = values["embed-auto-fallback"] === true ? true : undefined;
+    if (values["embed-auto-fallback"] === true) {
+        throw commercialApiHold("embedding auto-fallback is forbidden");
+    }
     return {
         ...(providerCliKind ? { kind: providerCliKind } : {}),
         ...(openai ? { openai } : {}),
-        ...(autoFallback !== undefined ? { autoFallback } : {}),
     };
 }
 function optionalString(v) {
@@ -2204,14 +2204,14 @@ function parseCLI() {
             force: { type: "boolean", short: "f" },
             "max-docs-per-batch": { type: "string" },
             "max-batch-mb": { type: "string" },
-            provider: { type: "string" }, // "local" | "openai"
+            provider: { type: "string" }, // commercial OpenAI-compatible API
             "embed-endpoint": { type: "string" }, // OpenAI-compatible endpoint URL
             "embed-api-key": { type: "string" }, // Bearer token
             "embed-model-id": { type: "string" }, // Stable model id (default: embeddinggemma)
             "embed-upstream-model": { type: "string" }, // Upstream model name in HTTP body
             "embed-batch-size": { type: "string" }, // Batch size for HTTP provider
             "embed-timeout-ms": { type: "string" }, // Per-request timeout
-            "embed-auto-fallback": { type: "boolean" }, // Wrap openai in AutoFallback (local fallback)
+            "embed-auto-fallback": { type: "boolean" }, // forbidden compatibility flag
             "no-vacuum": { type: "boolean" }, // cleanup: skip VACUUM for cron-safe pruning
             // Update options
             pull: { type: "boolean" }, // git pull before update

+ 4 - 10
dist/embedding/autofallback.d.ts

@@ -1,13 +1,8 @@
 /**
  * autofallback.ts - AutoFallbackEmbeddingProvider.
  *
- * Composes a primary `EmbeddingProvider` (typically `OpenAIEmbeddingsProvider`)
- * and a fallback (typically `LocalLlamaCppProvider`). When the primary trips
- * its circuit breaker — or when persistent failures cross a threshold — calls
- * are routed to the fallback. After a recovery cooldown, the primary is
- * probed again; success closes the breaker and routing returns.
- *
- * Acceptance criterion 4 from i-qkarfffa: "Endpoint down → fallback local + WARN".
+ * Historical internal helper for two commercial API providers. It is not
+ * exported by the package or selected by the factory.
  *
  * Behavior summary:
  *   - Primary call succeeds → return; record success.
@@ -59,9 +54,8 @@ export declare class AutoFallbackEmbeddingProvider implements EmbeddingProvider
     /**
      * Stable model id reported by the primary. The model-id guard runs against
      * the primary's id because that's what callers actually want when the
-     * remote endpoint is online; on fallback-only operation, the local
-     * provider should report a compatible id (in the default config, both
-     * report "embeddinggemma" so this is moot).
+     * remote endpoint is online; a secondary commercial provider must report a
+     * compatible id.
      */
     getModelId(): string;
     getDimensions(): number | undefined;

+ 6 - 10
dist/embedding/autofallback.js

@@ -1,13 +1,8 @@
 /**
  * autofallback.ts - AutoFallbackEmbeddingProvider.
  *
- * Composes a primary `EmbeddingProvider` (typically `OpenAIEmbeddingsProvider`)
- * and a fallback (typically `LocalLlamaCppProvider`). When the primary trips
- * its circuit breaker — or when persistent failures cross a threshold — calls
- * are routed to the fallback. After a recovery cooldown, the primary is
- * probed again; success closes the breaker and routing returns.
- *
- * Acceptance criterion 4 from i-qkarfffa: "Endpoint down → fallback local + WARN".
+ * Historical internal helper for two commercial API providers. It is not
+ * exported by the package or selected by the factory.
  *
  * Behavior summary:
  *   - Primary call succeeds → return; record success.
@@ -22,6 +17,7 @@
  *     provider is currently active (or to the primary if both are usable).
  */
 import { CircuitOpenError } from "./openai.js";
+import { commercialApiHold } from "../model-policy.js";
 const DEFAULT_FAILURE_STREAK = 3;
 const DEFAULT_COOLDOWN_MS = 5 * 60_000;
 function defaultWarn(msg) {
@@ -39,6 +35,7 @@ export class AutoFallbackEmbeddingProvider {
     fallbackUntil = null;
     lastTransitionState = "primary";
     constructor(config) {
+        throw commercialApiHold("automatic provider fallback is disabled; commercial API failures must remain HOLD");
         if (!config.primary)
             throw new Error("AutoFallbackEmbeddingProvider: primary is required");
         if (!config.fallback)
@@ -58,9 +55,8 @@ export class AutoFallbackEmbeddingProvider {
     /**
      * Stable model id reported by the primary. The model-id guard runs against
      * the primary's id because that's what callers actually want when the
-     * remote endpoint is online; on fallback-only operation, the local
-     * provider should report a compatible id (in the default config, both
-     * report "embeddinggemma" so this is moot).
+     * remote endpoint is online; a secondary commercial provider must report a
+     * compatible id.
      */
     getModelId() {
         return this.primary.getModelId();

+ 1 - 8
dist/embedding/factory.d.ts

@@ -24,8 +24,6 @@ export type EmbedProviderConfigFile = {
          */
         concurrency?: number;
         timeoutMs?: number;
-        /** Historical only. `true` is rejected because local fallback is forbidden. */
-        autoFallback?: boolean;
     };
 };
 export declare function defaultConfigPath(): string;
@@ -35,17 +33,12 @@ export declare function defaultConfigPath(): string;
  */
 export declare function loadConfigFile(path?: string): EmbedProviderConfigFile;
 export type CreateEmbeddingProviderOptions = {
-    /** Force a specific provider kind. Overrides env + config. */
+    /** Force the commercial provider kind. Overrides env + config. */
     kind?: ProviderKind;
     /** Override config file path (mostly for tests) */
     configPath?: string;
     /** OpenAI-provider overrides — merged on top of env/config */
     openai?: Partial<OpenAIProviderConfig>;
-    /**
-     * Historical compatibility input. Any truthy value produces typed HOLD;
-     * commercial provider failures must never fall back to a local model.
-     */
-    autoFallback?: boolean;
     /**
      * Custom env source (mostly for tests). Defaults to `process.env`.
      * Read keys: QMD_EMBED_PROVIDER, QMD_EMBED_ENDPOINT, QMD_EMBED_API_KEY,

+ 22 - 15
dist/embedding/factory.js

@@ -43,11 +43,15 @@ export function resolveProviderKind(opts = {}) {
     const env = opts.env ?? process.env;
     const cfg = loadConfigFile(opts.configPath);
     // 1. Explicit kind argument
-    if (opts.kind === "local") {
+    const explicitKind = opts.kind;
+    if (explicitKind === "local") {
         throw commercialApiHold('provider kind "local" is disabled; configure an approved commercial API');
     }
-    if (opts.kind === "openai")
-        return opts.kind;
+    if (explicitKind === "openai")
+        return "openai";
+    if (explicitKind) {
+        throw commercialApiHold(`unsupported commercial provider kind "${explicitKind}"`);
+    }
     // 2a. Explicit env override
     const envKind = env.QMD_EMBED_PROVIDER?.trim().toLowerCase();
     if (envKind === "local") {
@@ -55,17 +59,24 @@ export function resolveProviderKind(opts = {}) {
     }
     if (envKind === "openai")
         return envKind;
+    if (envKind) {
+        throw commercialApiHold(`unsupported QMD_EMBED_PROVIDER=${envKind}`);
+    }
     // 2b. Endpoint env present → openai
     if (env.QMD_EMBED_ENDPOINT && env.QMD_EMBED_ENDPOINT.trim() !== "") {
         return "openai";
     }
     // 3. Config file
-    if (cfg.embedProvider?.kind === "local") {
+    const configKind = cfg.embedProvider?.kind;
+    if (configKind === "local") {
         throw commercialApiHold("embedProvider.kind=local is forbidden");
     }
-    if (cfg.embedProvider?.kind === "openai") {
+    if (configKind === "openai") {
         return "openai";
     }
+    if (configKind) {
+        throw commercialApiHold(`unsupported embedProvider.kind=${configKind}`);
+    }
     if (cfg.embedProvider?.endpoint && cfg.embedProvider.endpoint.trim() !== "") {
         return "openai";
     }
@@ -79,10 +90,7 @@ export function resolveProviderKind(opts = {}) {
 export function createEmbeddingProvider(opts = {}) {
     const env = opts.env ?? process.env;
     const cfg = loadConfigFile(opts.configPath);
-    const kind = resolveProviderKind(opts);
-    if (kind === "local") {
-        throw commercialApiHold('provider kind "local" is disabled');
-    }
+    resolveProviderKind(opts);
     // OpenAI
     const endpoint = opts.openai?.endpoint ??
         env.QMD_EMBED_ENDPOINT ??
@@ -126,7 +134,7 @@ export function createEmbeddingProvider(opts = {}) {
         now: opts.openai?.now,
     });
     // Historical fallback inputs are rejected instead of silently weakening policy.
-    const autoFallback = resolveAutoFallback(opts, env, cfg);
+    const autoFallback = resolveAutoFallback(env, cfg);
     if (autoFallback) {
         throw commercialApiHold("local auto-fallback is forbidden; commercial API failures must remain HOLD");
     }
@@ -148,16 +156,15 @@ export function assertCommercialEndpoint(endpoint) {
         throw commercialApiHold(`endpoint ${parsed.protocol}//${host} is local, private, or non-TLS; use an approved commercial HTTPS API`);
     }
 }
-function resolveAutoFallback(opts, env, cfg) {
-    if (typeof opts.autoFallback === "boolean")
-        return opts.autoFallback;
+function resolveAutoFallback(env, cfg) {
     const envVal = env.QMD_EMBED_AUTO_FALLBACK?.trim().toLowerCase();
     if (envVal === "1" || envVal === "true" || envVal === "yes")
         return true;
     if (envVal === "0" || envVal === "false" || envVal === "no")
         return false;
-    if (typeof cfg.embedProvider?.autoFallback === "boolean") {
-        return cfg.embedProvider.autoFallback;
+    const configAutoFallback = cfg.embedProvider?.autoFallback;
+    if (typeof configAutoFallback === "boolean") {
+        return configAutoFallback;
     }
     return false;
 }

+ 0 - 2
dist/embedding/index.d.ts

@@ -2,7 +2,5 @@
  * embedding/index.ts - re-exports for the embedding provider abstraction.
  */
 export { type EmbeddingProvider, type ProviderKind, type ProviderEmbedding, type ProviderEmbedOptions, type ProviderHealth, ModelMismatchError, assertModelCompatible, } from "./provider.js";
-export { LocalLlamaCppProvider, type LocalLlamaCppProviderConfig, } from "./local.js";
 export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, type OpenAIProviderConfig, type CircuitState, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
 export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, type CreateEmbeddingProviderOptions, type EmbedProviderConfigFile, } from "./factory.js";
-export { AutoFallbackEmbeddingProvider, type AutoFallbackProviderConfig, type FallbackState, } from "./autofallback.js";

+ 0 - 2
dist/embedding/index.js

@@ -2,7 +2,5 @@
  * embedding/index.ts - re-exports for the embedding provider abstraction.
  */
 export { ModelMismatchError, assertModelCompatible, } from "./provider.js";
-export { LocalLlamaCppProvider, } from "./local.js";
 export { OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, isRetryableStatus, chunkArray, DEFAULT_BATCH_SIZE, DEFAULT_TIMEOUT_MS, RETRY_BACKOFFS_MS, } from "./openai.js";
 export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, loadConfigFile, defaultConfigPath, } from "./factory.js";
-export { AutoFallbackEmbeddingProvider, } from "./autofallback.js";

+ 2 - 11
dist/embedding/local.d.ts

@@ -1,18 +1,9 @@
 import type { LlamaCpp } from "../llm.js";
-import type { EmbeddingProvider, ProviderEmbedOptions, ProviderEmbedding, ProviderHealth, ProviderKind } from "./provider.js";
 export type LocalLlamaCppProviderConfig = {
     llm?: LlamaCpp;
     modelId?: string;
 };
-/** Historical SDK symbol. Construction is blocked by the commercial-only policy. */
-export declare class LocalLlamaCppProvider implements EmbeddingProvider {
-    readonly kind: ProviderKind;
+/** Historical direct-import symbol. It is not part of the package API. */
+export declare class LocalLlamaCppProvider {
     constructor(_config?: LocalLlamaCppProviderConfig);
-    getModelId(): string;
-    getDimensions(): number | undefined;
-    getLastError(): string | undefined;
-    healthcheck(_signal?: AbortSignal): Promise<ProviderHealth>;
-    embed(_text: string, _options?: ProviderEmbedOptions): Promise<ProviderEmbedding | null>;
-    embedBatch(_texts: string[], _options?: ProviderEmbedOptions): Promise<(ProviderEmbedding | null)[]>;
-    dispose(): Promise<void>;
 }

+ 1 - 15
dist/embedding/local.js

@@ -1,21 +1,7 @@
 import { commercialApiHold } from "../model-policy.js";
-/** Historical SDK symbol. Construction is blocked by the commercial-only policy. */
+/** Historical direct-import symbol. It is not part of the package API. */
 export class LocalLlamaCppProvider {
-    kind = "local";
     constructor(_config = {}) {
         throw commercialApiHold("LocalLlamaCppProvider is disabled; configure an approved commercial API");
     }
-    getModelId() { return "disabled-local-provider"; }
-    getDimensions() { return undefined; }
-    getLastError() { return "QMD_COMMERCIAL_API_HOLD"; }
-    async healthcheck(_signal) {
-        throw commercialApiHold("local provider healthcheck is disabled");
-    }
-    async embed(_text, _options = {}) {
-        throw commercialApiHold("local embedding is disabled");
-    }
-    async embedBatch(_texts, _options = {}) {
-        throw commercialApiHold("local batch embedding is disabled");
-    }
-    async dispose() { }
 }

+ 115 - 4
dist/embedding/openai.d.ts

@@ -10,9 +10,16 @@
  * Features:
  *   - Batches input in groups of ≤64 (configurable via QMD_EMBED_BATCH_SIZE)
  *   - Retries 429 / 503 with exponential backoff (1s, 4s, 16s)
+ *   - 429 gets its own, larger retry budget and honours `Retry-After`
+ *     (header or `"Retry after 29s"` body text) — a shared gateway bucket
+ *     refilling in 30s must not exhaust a 1s/4s/16s schedule (i-yghj098h)
+ *   - Bulk lane (`embedBatch` with >1 input): a 429 pauses the WHOLE worker
+ *     pool for the advertised cooldown and halves in-flight concurrency
+ *     (AIMD), so reindex traffic yields the bucket to interactive callers
+ *     instead of contending head-on with them
  *   - 4xx (non-429) → no retry, count as failure
- *   - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min,
- *     callers receive failures; local fallback is forbidden
+ *   - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min;
+ *     callers receive failures and no model fallback is selected
  *   - Per-call timeout via AbortSignal (default QMD_EMBED_TIMEOUT_MS=30000)
  *   - Healthcheck via `GET /health` if available, else a probe embed call
  */
@@ -41,6 +48,27 @@ export declare const DEFAULT_TIMEOUT_MS = 30000;
  * (initial + 2 retries) — aligns with issue spec "1s/4s/16s".
  */
 export declare const RETRY_BACKOFFS_MS: readonly number[];
+/**
+ * Rate-limit (429) retry budget, separate from `RETRY_BACKOFFS_MS`.
+ *
+ * A 429 is backpressure, not a fault: the shared ai.mm.mk gateway answers
+ * `{"detail":"Rate limit exceeded (tokens). Retry after 29s."}` with observed
+ * retry-after values of 7s/29s/30s, while the generic schedule waits at most
+ * 1+4+16 = 21s in total. Bulk reindex runs therefore burned all three attempts
+ * inside one bucket refill and reported the chunk as failed (i-yghj098h).
+ * 429s get their own attempt count and always wait at least as long as the
+ * server asked for, capped by `RATE_LIMIT_MAX_BACKOFF_MS`.
+ */
+export declare const DEFAULT_RATE_LIMIT_RETRIES = 5;
+/** Upper bound on a single rate-limit wait, so a bogus Retry-After can't hang a run. */
+export declare const RATE_LIMIT_MAX_BACKOFF_MS = 60000;
+/** Fallback wait when a 429 carries no parseable Retry-After (doubles per attempt). */
+export declare const RATE_LIMIT_BASE_BACKOFF_MS = 5000;
+/**
+ * Consecutive successful bulk requests before the lane additively recovers one
+ * unit of concurrency after a 429-triggered halving (AIMD).
+ */
+export declare const LANE_RECOVERY_STREAK = 8;
 /**
  * Circuit breaker — flips OPEN when error rate exceeds threshold within
  * window. While OPEN, every call fails fast so the caller can fall back.
@@ -78,6 +106,12 @@ export type OpenAIProviderConfig = {
     fetchImpl?: typeof fetch;
     /** Custom retry schedule (for testing). Defaults to RETRY_BACKOFFS_MS. */
     retryBackoffsMs?: readonly number[];
+    /**
+     * Extra retry attempts granted to 429 responses on top of the generic
+     * schedule (default DEFAULT_RATE_LIMIT_RETRIES = 5, env
+     * `QMD_EMBED_RATE_LIMIT_RETRIES`). Rate limits are backpressure, not faults.
+     */
+    rateLimitRetries?: number;
     /** Custom sleep impl (for testing). Defaults to setTimeout. */
     sleep?: (ms: number) => Promise<void>;
     /** Custom clock (for testing). Defaults to Date.now. */
@@ -100,11 +134,31 @@ export type OpenAIEmbeddingsResponse = {
  * Circuit breaker state — exported for tests
  */
 export type CircuitState = "closed" | "open" | "half-open";
+/**
+ * Traffic lane for a single `embedBatch` call. `bulk` = reindex traffic
+ * (multi-input batch), which self-throttles via `BulkLaneGate` and is
+ * attributed separately in `X-AI-Caller`. `interactive` = query-time embeds,
+ * which are never made to wait behind a bulk cooldown.
+ */
+export type EmbedLane = "interactive" | "bulk";
 /**
  * Determine whether an HTTP status is retryable. 429 (Too Many Requests)
  * and 503 (Service Unavailable) are retried; 4xx (other than 429) are not.
  */
 export declare function isRetryableStatus(status: number): boolean;
+/**
+ * Extract the server-advertised cooldown from a rate-limited response.
+ *
+ * Two sources, in priority order:
+ *   1. the standard `Retry-After` header — delta-seconds or an HTTP-date;
+ *   2. the ai.mm.mk body text, which states the cooldown in prose only:
+ *      `{"detail":"Rate limit exceeded (tokens). Retry after 29s.", ...}`.
+ *
+ * Returns `undefined` when neither source yields a sane positive duration, so
+ * the caller falls back to its own schedule. Values are clamped to
+ * `RATE_LIMIT_MAX_BACKOFF_MS`.
+ */
+export declare function parseRetryAfterMs(headerValue: string | null | undefined, bodyPreview?: string, now?: () => number): number | undefined;
 /**
  * Chunk an array into pieces of ≤ size each. `size` MUST be ≥ 1.
  */
@@ -149,7 +203,6 @@ export declare class CircuitBreaker {
 }
 /**
  * Raised when the circuit breaker is OPEN and a call is short-circuited.
- * Callers (e.g. fallback wrapper) can catch this to switch to local provider.
  */
 export declare class CircuitOpenError extends Error {
     constructor(message?: string);
@@ -160,7 +213,55 @@ export declare class CircuitOpenError extends Error {
 export declare class HttpError extends Error {
     readonly status: number;
     readonly bodyPreview: string;
-    constructor(status: number, bodyPreview: string);
+    /** Server-advertised cooldown for 429s, when the response stated one. */
+    readonly retryAfterMs?: number;
+    constructor(status: number, bodyPreview: string, retryAfterMs?: number);
+}
+/**
+ * Client-side bulk lane for reindex traffic (i-yghj098h).
+ *
+ * qmd's bulk embedding shares the interactive ai.mm.mk token bucket. Without a
+ * server-side per-caller budget, the only way bulk traffic can stop contending
+ * head-on with interactive callers is to police itself:
+ *
+ *   - a 429 on ANY worker pauses the ENTIRE pool for the advertised cooldown
+ *     (one shared promise — concurrent 429s coalesce instead of stacking N
+ *     cooldowns), so the bucket refills for interactive callers rather than
+ *     being re-drained by the remaining workers;
+ *   - the in-flight cap halves on each cooldown (floor 1) and recovers one unit
+ *     per `LANE_RECOVERY_STREAK` successes — classic AIMD, so a run settles at
+ *     whatever share the bucket actually has spare.
+ *
+ * Deliberately clock-free: cooldowns are modelled as a promise produced by the
+ * injected `sleep`, so tests drive them with a fake sleep and no fake clock.
+ */
+export declare class BulkLaneGate {
+    private readonly maxPermits;
+    private readonly sleep;
+    private permits;
+    private inFlight;
+    private okStreak;
+    private cooldown;
+    private waiters;
+    constructor(maxPermits: number, sleep: (ms: number) => Promise<void>);
+    /** Current in-flight cap — exported state for tests/diagnostics. */
+    get permitCount(): number;
+    /** True while the lane is serving a rate-limit cooldown. */
+    get isCoolingDown(): boolean;
+    /** Take a slot, waiting out any cooldown and respecting the current cap. */
+    acquire(): Promise<void>;
+    /** Return a slot. Always call from a `finally`. */
+    release(): void;
+    /**
+     * Enter (or join) a cooldown of `waitMs` and halve the in-flight cap.
+     * Returns the shared cooldown promise — the caller awaits it INSTEAD of
+     * sleeping itself, so a burst of 429s costs one cooldown, not one each.
+     */
+    penalize(waitMs: number): Promise<void>;
+    /** Record a successful bulk request; recovers one permit per success streak. */
+    noteSuccess(): void;
+    private wakeOne;
+    private wakeAll;
 }
 export declare class OpenAIEmbeddingsProvider implements EmbeddingProvider {
     readonly kind: ProviderKind;
@@ -173,11 +274,14 @@ export declare class OpenAIEmbeddingsProvider implements EmbeddingProvider {
     private readonly timeoutMs;
     private readonly fetchImpl;
     private readonly retryBackoffsMs;
+    private readonly rateLimitRetries;
     private readonly sleep;
     private readonly now;
     private dimensions;
     private lastError;
     readonly breaker: CircuitBreaker;
+    /** Shared bulk-traffic lane — see `BulkLaneGate`. */
+    readonly lane: BulkLaneGate;
     constructor(config: OpenAIProviderConfig);
     getModelId(): string;
     getDimensions(): number | undefined;
@@ -210,6 +314,13 @@ export declare class OpenAIEmbeddingsProvider implements EmbeddingProvider {
      * exhausted.
      */
     private requestWithRetry;
+    /**
+     * How long to wait after a 429. The server's own `Retry-After` wins when it
+     * gave one; otherwise fall back to the configured schedule (so injected test
+     * schedules stay authoritative) and then to an exponential 5s/10s/20s… ramp.
+     * Always clamped to `RATE_LIMIT_MAX_BACKOFF_MS`.
+     */
+    private rateLimitWaitMs;
     /**
      * Issue one HTTP attempt to `POST /v1/embeddings`. Does NOT retry.
      */

+ 258 - 19
dist/embedding/openai.js

@@ -10,9 +10,16 @@
  * Features:
  *   - Batches input in groups of ≤64 (configurable via QMD_EMBED_BATCH_SIZE)
  *   - Retries 429 / 503 with exponential backoff (1s, 4s, 16s)
+ *   - 429 gets its own, larger retry budget and honours `Retry-After`
+ *     (header or `"Retry after 29s"` body text) — a shared gateway bucket
+ *     refilling in 30s must not exhaust a 1s/4s/16s schedule (i-yghj098h)
+ *   - Bulk lane (`embedBatch` with >1 input): a 429 pauses the WHOLE worker
+ *     pool for the advertised cooldown and halves in-flight concurrency
+ *     (AIMD), so reindex traffic yields the bucket to interactive callers
+ *     instead of contending head-on with them
  *   - 4xx (non-429) → no retry, count as failure
- *   - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min,
- *     callers receive failures; local fallback is forbidden
+ *   - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min;
+ *     callers receive failures and no model fallback is selected
  *   - Per-call timeout via AbortSignal (default QMD_EMBED_TIMEOUT_MS=30000)
  *   - Healthcheck via `GET /health` if available, else a probe embed call
  */
@@ -42,6 +49,27 @@ export const DEFAULT_TIMEOUT_MS = 30_000;
  * (initial + 2 retries) — aligns with issue spec "1s/4s/16s".
  */
 export const RETRY_BACKOFFS_MS = [1_000, 4_000, 16_000];
+/**
+ * Rate-limit (429) retry budget, separate from `RETRY_BACKOFFS_MS`.
+ *
+ * A 429 is backpressure, not a fault: the shared ai.mm.mk gateway answers
+ * `{"detail":"Rate limit exceeded (tokens). Retry after 29s."}` with observed
+ * retry-after values of 7s/29s/30s, while the generic schedule waits at most
+ * 1+4+16 = 21s in total. Bulk reindex runs therefore burned all three attempts
+ * inside one bucket refill and reported the chunk as failed (i-yghj098h).
+ * 429s get their own attempt count and always wait at least as long as the
+ * server asked for, capped by `RATE_LIMIT_MAX_BACKOFF_MS`.
+ */
+export const DEFAULT_RATE_LIMIT_RETRIES = 5;
+/** Upper bound on a single rate-limit wait, so a bogus Retry-After can't hang a run. */
+export const RATE_LIMIT_MAX_BACKOFF_MS = 60_000;
+/** Fallback wait when a 429 carries no parseable Retry-After (doubles per attempt). */
+export const RATE_LIMIT_BASE_BACKOFF_MS = 5_000;
+/**
+ * Consecutive successful bulk requests before the lane additively recovers one
+ * unit of concurrency after a 429-triggered halving (AIMD).
+ */
+export const LANE_RECOVERY_STREAK = 8;
 /**
  * Circuit breaker — flips OPEN when error rate exceeds threshold within
  * window. While OPEN, every call fails fast so the caller can fall back.
@@ -54,6 +82,13 @@ export const CIRCUIT_MIN_SAMPLES = 4;
 function defaultSleep(ms) {
     return new Promise((resolve) => setTimeout(resolve, ms));
 }
+/** Parse a non-negative integer env value; `undefined` when unset/invalid. */
+function parseNonNegativeInt(raw) {
+    if (raw == null || raw.trim() === "")
+        return undefined;
+    const n = Number.parseInt(raw, 10);
+    return Number.isFinite(n) && n >= 0 ? n : undefined;
+}
 /**
  * Build the advisory `X-AI-Caller` attribution header value (Oivo ai.mm.mk
  * "Observability-Driven Fleet Self-Improvement" rollout, Thread 1). Format
@@ -129,6 +164,44 @@ function buildAttemptSignal(userSignal, timeoutMs) {
 export function isRetryableStatus(status) {
     return status === 429 || status === 503;
 }
+/**
+ * Extract the server-advertised cooldown from a rate-limited response.
+ *
+ * Two sources, in priority order:
+ *   1. the standard `Retry-After` header — delta-seconds or an HTTP-date;
+ *   2. the ai.mm.mk body text, which states the cooldown in prose only:
+ *      `{"detail":"Rate limit exceeded (tokens). Retry after 29s.", ...}`.
+ *
+ * Returns `undefined` when neither source yields a sane positive duration, so
+ * the caller falls back to its own schedule. Values are clamped to
+ * `RATE_LIMIT_MAX_BACKOFF_MS`.
+ */
+export function parseRetryAfterMs(headerValue, bodyPreview, now = Date.now) {
+    const clamp = (ms) => Number.isFinite(ms) && ms > 0 ? Math.min(ms, RATE_LIMIT_MAX_BACKOFF_MS) : undefined;
+    const header = headerValue?.trim();
+    if (header) {
+        if (/^\d+(\.\d+)?$/.test(header)) {
+            const fromSeconds = clamp(Number(header) * 1000);
+            if (fromSeconds !== undefined)
+                return fromSeconds;
+        }
+        const asDate = Date.parse(header);
+        if (!Number.isNaN(asDate)) {
+            const fromDate = clamp(asDate - now());
+            if (fromDate !== undefined)
+                return fromDate;
+        }
+    }
+    if (bodyPreview) {
+        const m = /retry\s+after\s+(\d+(?:\.\d+)?)\s*(ms|s|seconds?)?/i.exec(bodyPreview);
+        if (m) {
+            const value = Number(m[1]);
+            const unit = (m[2] ?? "s").toLowerCase();
+            return clamp(unit === "ms" ? value : value * 1000);
+        }
+    }
+    return undefined;
+}
 /**
  * Chunk an array into pieces of ≤ size each. `size` MUST be ≥ 1.
  */
@@ -241,7 +314,6 @@ export class CircuitBreaker {
 // ─────────────────────────── Errors ──────────────────────────────────────────
 /**
  * Raised when the circuit breaker is OPEN and a call is short-circuited.
- * Callers (e.g. fallback wrapper) can catch this to switch to local provider.
  */
 export class CircuitOpenError extends Error {
     constructor(message = "OpenAIEmbeddingsProvider circuit is OPEN") {
@@ -255,11 +327,109 @@ export class CircuitOpenError extends Error {
 export class HttpError extends Error {
     status;
     bodyPreview;
-    constructor(status, bodyPreview) {
+    /** Server-advertised cooldown for 429s, when the response stated one. */
+    retryAfterMs;
+    constructor(status, bodyPreview, retryAfterMs) {
         super(`HTTP ${status}: ${bodyPreview.slice(0, 200)}`);
         this.name = "HttpError";
         this.status = status;
         this.bodyPreview = bodyPreview.slice(0, 1024);
+        this.retryAfterMs = retryAfterMs;
+    }
+}
+// ─────────────────────────── Bulk lane ───────────────────────────────────────
+/**
+ * Client-side bulk lane for reindex traffic (i-yghj098h).
+ *
+ * qmd's bulk embedding shares the interactive ai.mm.mk token bucket. Without a
+ * server-side per-caller budget, the only way bulk traffic can stop contending
+ * head-on with interactive callers is to police itself:
+ *
+ *   - a 429 on ANY worker pauses the ENTIRE pool for the advertised cooldown
+ *     (one shared promise — concurrent 429s coalesce instead of stacking N
+ *     cooldowns), so the bucket refills for interactive callers rather than
+ *     being re-drained by the remaining workers;
+ *   - the in-flight cap halves on each cooldown (floor 1) and recovers one unit
+ *     per `LANE_RECOVERY_STREAK` successes — classic AIMD, so a run settles at
+ *     whatever share the bucket actually has spare.
+ *
+ * Deliberately clock-free: cooldowns are modelled as a promise produced by the
+ * injected `sleep`, so tests drive them with a fake sleep and no fake clock.
+ */
+export class BulkLaneGate {
+    maxPermits;
+    sleep;
+    permits;
+    inFlight = 0;
+    okStreak = 0;
+    cooldown = null;
+    waiters = [];
+    constructor(maxPermits, sleep) {
+        this.maxPermits = Math.max(1, maxPermits);
+        this.permits = this.maxPermits;
+        this.sleep = sleep;
+    }
+    /** Current in-flight cap — exported state for tests/diagnostics. */
+    get permitCount() {
+        return this.permits;
+    }
+    /** True while the lane is serving a rate-limit cooldown. */
+    get isCoolingDown() {
+        return this.cooldown !== null;
+    }
+    /** Take a slot, waiting out any cooldown and respecting the current cap. */
+    async acquire() {
+        while (this.cooldown !== null || this.inFlight >= this.permits) {
+            if (this.cooldown !== null) {
+                await this.cooldown;
+                continue;
+            }
+            await new Promise((resolve) => this.waiters.push(resolve));
+        }
+        this.inFlight++;
+    }
+    /** Return a slot. Always call from a `finally`. */
+    release() {
+        if (this.inFlight > 0)
+            this.inFlight--;
+        this.wakeOne();
+    }
+    /**
+     * Enter (or join) a cooldown of `waitMs` and halve the in-flight cap.
+     * Returns the shared cooldown promise — the caller awaits it INSTEAD of
+     * sleeping itself, so a burst of 429s costs one cooldown, not one each.
+     */
+    penalize(waitMs) {
+        if (this.cooldown !== null)
+            return this.cooldown;
+        this.okStreak = 0;
+        this.permits = Math.max(1, Math.floor(this.permits / 2));
+        const cooldown = this.sleep(waitMs).then(() => {
+            if (this.cooldown === cooldown)
+                this.cooldown = null;
+            this.wakeAll();
+        });
+        this.cooldown = cooldown;
+        return cooldown;
+    }
+    /** Record a successful bulk request; recovers one permit per success streak. */
+    noteSuccess() {
+        if (this.permits >= this.maxPermits)
+            return;
+        if (++this.okStreak >= LANE_RECOVERY_STREAK) {
+            this.okStreak = 0;
+            this.permits++;
+            this.wakeOne();
+        }
+    }
+    wakeOne() {
+        this.waiters.shift()?.();
+    }
+    wakeAll() {
+        const pending = this.waiters;
+        this.waiters = [];
+        for (const resolve of pending)
+            resolve();
     }
 }
 // ─────────────────────────── Provider ────────────────────────────────────────
@@ -274,11 +444,14 @@ export class OpenAIEmbeddingsProvider {
     timeoutMs;
     fetchImpl;
     retryBackoffsMs;
+    rateLimitRetries;
     sleep;
     now;
     dimensions = undefined;
     lastError = undefined;
     breaker;
+    /** Shared bulk-traffic lane — see `BulkLaneGate`. */
+    lane;
     constructor(config) {
         if (!config.endpoint) {
             throw new Error("OpenAIEmbeddingsProvider: endpoint is required");
@@ -292,9 +465,14 @@ export class OpenAIEmbeddingsProvider {
         this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
         this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
         this.retryBackoffsMs = config.retryBackoffsMs ?? RETRY_BACKOFFS_MS;
+        this.rateLimitRetries =
+            config.rateLimitRetries ??
+                parseNonNegativeInt(process.env.QMD_EMBED_RATE_LIMIT_RETRIES) ??
+                DEFAULT_RATE_LIMIT_RETRIES;
         this.sleep = config.sleep ?? defaultSleep;
         this.now = config.now ?? Date.now;
         this.breaker = new CircuitBreaker({ now: this.now });
+        this.lane = new BulkLaneGate(this.concurrency, this.sleep);
         if (!this.fetchImpl) {
             throw new Error("OpenAIEmbeddingsProvider: global fetch is unavailable. " +
                 "Provide a `fetchImpl` config option (Node ≥18 ships fetch by default).");
@@ -392,6 +570,11 @@ export class OpenAIEmbeddingsProvider {
         if (this.breaker.shouldFailFast()) {
             throw new CircuitOpenError();
         }
+        // Bulk = anything that isn't a single query-time embed. Reindex runs go
+        // through the self-throttling lane and are attributed as `embeddings-bulk`;
+        // `embed()` (search path) stays interactive and is never parked behind a
+        // bulk cooldown (i-yghj098h).
+        const lane = texts.length > 1 ? "bulk" : "interactive";
         const chunks = chunkArray(texts, this.batchSize);
         const results = new Array(texts.length).fill(null);
         // Pre-compute the input-array starting position for each chunk so each
@@ -442,7 +625,18 @@ export class OpenAIEmbeddingsProvider {
                     return;
                 }
                 try {
-                    const embeddings = await this.requestWithRetry(chunk, options);
+                    if (lane === "bulk")
+                        await this.lane.acquire();
+                    let embeddings;
+                    try {
+                        embeddings = await this.requestWithRetry(chunk, options, lane);
+                    }
+                    finally {
+                        if (lane === "bulk")
+                            this.lane.release();
+                    }
+                    if (lane === "bulk")
+                        this.lane.noteSuccess();
                     for (let i = 0; i < chunk.length; i++) {
                         const embedding = embeddings[i];
                         if (embedding) {
@@ -462,7 +656,13 @@ export class OpenAIEmbeddingsProvider {
                     this.breaker.recordSuccess();
                 }
                 catch (err) {
-                    this.breaker.recordFailure();
+                    // A rate limit is backpressure, not a fault. Counting 429s toward the
+                    // breaker turns "the bucket is empty for 30s" into a 5-minute hard
+                    // OPEN, which is the outage the retry budget above exists to avoid.
+                    // The lane cooldown is already the correct control response.
+                    const rateLimited = err instanceof HttpError && err.status === 429;
+                    if (!rateLimited)
+                        this.breaker.recordFailure();
                     if (err instanceof CircuitOpenError) {
                         circuitTrippedDuringRun = err;
                         return;
@@ -512,13 +712,16 @@ export class OpenAIEmbeddingsProvider {
         }
         return `endpoint=${this.endpoint}/v1/embeddings error="${String(err)}"`;
     }
-    buildHeaders() {
+    buildHeaders(lane = "interactive") {
         const headers = {
             "Content-Type": "application/json",
             "Accept": "application/json",
             // Advisory caller attribution for the ai.mm.mk gateway (NULL-safe, never
             // auth). Single chokepoint for both /health and /v1/embeddings.
-            "X-AI-Caller": aiCallerHeaderValue("embeddings"),
+            // `via` distinguishes bulk reindex traffic from query-time embeds so the
+            // gateway can budget the two separately (i-yghj098h part 1b) — the
+            // gateway-side per-caller bucket keys off this label.
+            "X-AI-Caller": aiCallerHeaderValue(lane === "bulk" ? "embeddings-bulk" : "embeddings"),
         };
         if (this.apiKey) {
             headers["Authorization"] = `Bearer ${this.apiKey}`;
@@ -530,34 +733,65 @@ export class OpenAIEmbeddingsProvider {
      * the same as `texts`. Throws on non-retryable failure or all attempts
      * exhausted.
      */
-    async requestWithRetry(texts, options) {
+    async requestWithRetry(texts, options, lane = "interactive") {
         let lastErr = null;
-        const maxAttempts = this.retryBackoffsMs.length + 1;
-        for (let attempt = 0; attempt < maxAttempts; attempt++) {
+        // 429s draw on their own budget: a token bucket that refills in 30s must
+        // not be able to exhaust a schedule whose total wait is 21s (i-yghj098h).
+        let backoffAttempt = 0;
+        let rateLimitAttempt = 0;
+        for (;;) {
             // Honor user abort BEFORE issuing the call (avoids wasted network)
             if (options.signal?.aborted) {
                 throw new Error("aborted by caller");
             }
             try {
-                return await this.requestOnce(texts, options);
+                return await this.requestOnce(texts, options, lane);
             }
             catch (err) {
                 lastErr = err;
-                const retryable = err instanceof HttpError ? isRetryableStatus(err.status) : false;
-                if (!retryable)
+                const status = err instanceof HttpError ? err.status : 0;
+                if (!isRetryableStatus(status))
                     throw err;
-                if (attempt < this.retryBackoffsMs.length) {
-                    await this.sleep(this.retryBackoffsMs[attempt]);
+                if (status === 429) {
+                    if (rateLimitAttempt >= this.rateLimitRetries)
+                        throw err;
+                    const waitMs = this.rateLimitWaitMs(err, rateLimitAttempt);
+                    rateLimitAttempt++;
+                    if (lane === "bulk") {
+                        // Pause the whole pool, not just this worker: the other workers
+                        // hammering the same empty bucket is exactly what turns one 429
+                        // into a cascade, and what starves interactive callers.
+                        await this.lane.penalize(waitMs);
+                    }
+                    else {
+                        await this.sleep(waitMs);
+                    }
+                    continue;
                 }
+                if (backoffAttempt >= this.retryBackoffsMs.length)
+                    break;
+                await this.sleep(this.retryBackoffsMs[backoffAttempt]);
+                backoffAttempt++;
             }
         }
         // Exhausted retries → throw the last error so caller marks the chunk null
         throw lastErr ?? new Error("requestWithRetry exhausted");
     }
+    /**
+     * How long to wait after a 429. The server's own `Retry-After` wins when it
+     * gave one; otherwise fall back to the configured schedule (so injected test
+     * schedules stay authoritative) and then to an exponential 5s/10s/20s… ramp.
+     * Always clamped to `RATE_LIMIT_MAX_BACKOFF_MS`.
+     */
+    rateLimitWaitMs(err, rateLimitAttempt) {
+        const scheduled = this.retryBackoffsMs[Math.min(rateLimitAttempt, this.retryBackoffsMs.length - 1)];
+        const fallback = scheduled ?? RATE_LIMIT_BASE_BACKOFF_MS * Math.pow(2, rateLimitAttempt);
+        return Math.min(Math.max(err.retryAfterMs ?? 0, fallback), RATE_LIMIT_MAX_BACKOFF_MS);
+    }
     /**
      * Issue one HTTP attempt to `POST /v1/embeddings`. Does NOT retry.
      */
-    async requestOnce(texts, options) {
+    async requestOnce(texts, options, lane = "interactive") {
         const { signal: attemptSig, cleanup } = buildAttemptSignal(options.signal, this.timeoutMs);
         try {
             const body = JSON.stringify({
@@ -566,13 +800,18 @@ export class OpenAIEmbeddingsProvider {
             });
             const resp = await this.fetchImpl(`${this.endpoint}/v1/embeddings`, {
                 method: "POST",
-                headers: this.buildHeaders(),
+                headers: this.buildHeaders(lane),
                 body,
                 signal: attemptSig,
             });
             if (!resp.ok) {
                 const text = await resp.text().catch(() => "");
-                throw new HttpError(resp.status, text);
+                // ai.mm.mk states the cooldown in the JSON body, not always in a
+                // Retry-After header — read both (i-yghj098h).
+                const retryAfterMs = resp.status === 429
+                    ? parseRetryAfterMs(resp.headers?.get?.("retry-after"), text, this.now)
+                    : undefined;
+                throw new HttpError(resp.status, text, retryAfterMs);
             }
             let parsed;
             try {

+ 4 - 4
dist/embedding/provider.d.ts

@@ -1,8 +1,8 @@
 /**
  * provider.ts - Embedding provider abstraction
  *
- * Production embeddings use a commercial OpenAI-compatible API. The `local`
- * kind remains readable only for historical config and is rejected by the factory.
+ * Production embeddings use a commercial OpenAI-compatible API. Historical
+ * local configuration is parsed only by the factory so it can fail closed.
  *
  * The factory in `./factory.ts` selects an implementation based on env vars,
  * a CLI flag, or `~/.config/qmd/config.json`.
@@ -18,7 +18,7 @@ export type ProviderEmbedding = {
 /**
  * Supported provider kinds
  */
-export type ProviderKind = "local" | "openai";
+export type ProviderKind = "openai";
 /**
  * Healthcheck result for provider startup verification
  */
@@ -100,7 +100,7 @@ export interface EmbeddingProvider {
      * compatible; callers must guard with `provider.getLastError?.()`.
      */
     getLastError?(): string | undefined;
-    /** Release any held resources (HTTP keep-alive sockets, model handles, …) */
+    /** Release any held resources, such as HTTP keep-alive sockets. */
     dispose(): Promise<void>;
 }
 /**

+ 2 - 2
dist/embedding/provider.js

@@ -1,8 +1,8 @@
 /**
  * provider.ts - Embedding provider abstraction
  *
- * Production embeddings use a commercial OpenAI-compatible API. The `local`
- * kind remains readable only for historical config and is rejected by the factory.
+ * Production embeddings use a commercial OpenAI-compatible API. Historical
+ * local configuration is parsed only by the factory so it can fail closed.
  *
  * The factory in `./factory.ts` selects an implementation based on env vars,
  * a CLI flag, or `~/.config/qmd/config.json`.

+ 11 - 13
dist/index.d.ts

@@ -25,7 +25,7 @@ export type { ChunkStrategy } from "./store.js";
 export { getDefaultDbPath } from "./store.js";
 export { Maintenance } from "./maintenance.js";
 import type { EmbeddingProvider } from "./embedding/index.js";
-export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, LocalLlamaCppProvider, OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, ModelMismatchError, assertModelCompatible, type EmbeddingProvider, type ProviderKind, type ProviderEmbedding, type ProviderEmbedOptions, type ProviderHealth, type CreateEmbeddingProviderOptions, type OpenAIProviderConfig, type LocalLlamaCppProviderConfig, type EmbedProviderConfigFile, DEFAULT_BATCH_SIZE as DEFAULT_PROVIDER_BATCH_SIZE, DEFAULT_TIMEOUT_MS as DEFAULT_PROVIDER_TIMEOUT_MS, RETRY_BACKOFFS_MS as PROVIDER_RETRY_BACKOFFS_MS, } from "./embedding/index.js";
+export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, ModelMismatchError, assertModelCompatible, type EmbeddingProvider, type ProviderKind, type ProviderEmbedding, type ProviderEmbedOptions, type ProviderHealth, type CreateEmbeddingProviderOptions, type OpenAIProviderConfig, type EmbedProviderConfigFile, DEFAULT_BATCH_SIZE as DEFAULT_PROVIDER_BATCH_SIZE, DEFAULT_TIMEOUT_MS as DEFAULT_PROVIDER_TIMEOUT_MS, RETRY_BACKOFFS_MS as PROVIDER_RETRY_BACKOFFS_MS, } from "./embedding/index.js";
 export { CommercialApiHoldError, COMMERCIAL_API_HOLD_CODE, commercialApiHold, } from "./model-policy.js";
 export { getDistinctEmbeddingModels } from "./store.js";
 /**
@@ -75,9 +75,8 @@ export interface SearchOptions {
     /**
      * Optional embedding provider for query-side encoding (i-loazq6ze).
      * When supplied, vec/hyde sub-queries are encoded through the provider
-     * (HTTP / GPU worker / AutoFallback chain) instead of the local llama-cpp
-     * model. Omit to keep pre-patch behavior — the SDK store still works
-     * unchanged for callers that have not opted into a remote provider.
+     * through the configured commercial API. Without one, learned work returns
+     * typed HOLD.
      */
     embedProvider?: EmbeddingProvider;
 }
@@ -96,7 +95,7 @@ export interface VectorSearchOptions {
     collection?: string;
     /**
      * Optional embedding provider for query encoding (i-loazq6ze). Forwarded
-     * through to `searchVec`. Defaults to local llama-cpp.
+     * through to `searchVec`. Without one, learned work returns typed HOLD.
      */
     embedProvider?: EmbeddingProvider;
 }
@@ -121,11 +120,10 @@ export interface StoreOptions {
     /** Inline collection config (mutually exclusive with `configPath`) */
     config?: CollectionConfig;
     /**
-     * Optional default embedding provider for query encoding (i-loazq6ze).
-     * When set, every `store.search(...)` call uses this provider unless the
-     * caller passes its own `embedProvider` in `SearchOptions`. MCP / HTTP
-     * server constructs the provider once at startup and injects it here so
-     * every query routes through the GPU worker.
+     * Optional default commercial embedding provider. When set, every learned
+     * embedding operation uses this provider unless a search call supplies its
+     * own `embedProvider`. MCP / HTTP constructs the provider once at startup
+     * and injects it here so every query routes through the approved API.
      */
     embedProvider?: EmbeddingProvider;
 }
@@ -133,8 +131,8 @@ export interface StoreOptions {
  * The QMD SDK store — provides search, retrieval, collection management,
  * context management, and indexing operations.
  *
- * All methods are async. The store manages its own LlamaCpp instance
- * (lazy-loaded, auto-unloaded after inactivity) — no global singletons.
+ * All methods are async. Learned work uses an injected commercial provider or
+ * the fail-closed compatibility adapter.
  */
 export interface QMDStore {
     /** The underlying internal store (for advanced use) */
@@ -220,7 +218,7 @@ export interface QMDStore {
     getStatus(): Promise<IndexStatus>;
     /** Get index health info (stale embeddings, etc.) */
     getIndexHealth(): Promise<IndexHealthInfo>;
-    /** Close the store and release all resources (LLM models, DB connection) */
+    /** Close the store and release provider and database resources. */
     close(): Promise<void>;
 }
 /**

+ 6 - 5
dist/index.js

@@ -26,9 +26,9 @@ export { getDefaultDbPath } from "./store.js";
 // Re-export Maintenance class for CLI housekeeping operations
 export { Maintenance } from "./maintenance.js";
 // Re-export embedding provider abstraction for SDK consumers (i-qkarfffa).
-// `createEmbeddingProvider` is commercial-only. The historical local symbol
-// remains exported for source compatibility but its constructor returns HOLD.
-export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, LocalLlamaCppProvider, OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, ModelMismatchError, assertModelCompatible, DEFAULT_BATCH_SIZE as DEFAULT_PROVIDER_BATCH_SIZE, DEFAULT_TIMEOUT_MS as DEFAULT_PROVIDER_TIMEOUT_MS, RETRY_BACKOFFS_MS as PROVIDER_RETRY_BACKOFFS_MS, } from "./embedding/index.js";
+// `createEmbeddingProvider` is commercial-only. Local and fallback providers
+// are intentionally absent from the package API.
+export { createEmbeddingProvider, resolveProviderKind, assertCommercialEndpoint, OpenAIEmbeddingsProvider, CircuitBreaker, CircuitOpenError, HttpError, ModelMismatchError, assertModelCompatible, DEFAULT_BATCH_SIZE as DEFAULT_PROVIDER_BATCH_SIZE, DEFAULT_TIMEOUT_MS as DEFAULT_PROVIDER_TIMEOUT_MS, RETRY_BACKOFFS_MS as PROVIDER_RETRY_BACKOFFS_MS, } from "./embedding/index.js";
 export { CommercialApiHoldError, COMMERCIAL_API_HOLD_CODE, commercialApiHold, } from "./model-policy.js";
 export { getDistinctEmbeddingModels } from "./store.js";
 /**
@@ -83,8 +83,8 @@ export async function createStore(options) {
         syncConfigToDb(db, config);
     }
     // else: DB-only mode — no external config, use existing store_collections
-    // Create a per-store LlamaCpp instance — lazy-loads models on first use,
-    // auto-unloads after 5 min inactivity to free VRAM.
+    // Compatibility adapter for learned operations that lack an injected
+    // commercial provider. Every such call returns typed HOLD.
     const llm = new LlamaCpp({
         embedModel: config?.models?.embed,
         generateModel: config?.models?.generate,
@@ -231,6 +231,7 @@ export async function createStore(options) {
                 maxBatchBytes: embedOpts?.maxBatchBytes,
                 chunkStrategy: embedOpts?.chunkStrategy,
                 onProgress: embedOpts?.onProgress,
+                embedProvider: options.embedProvider,
             });
         },
         // Index Health

+ 14 - 5
dist/store.d.ts

@@ -327,12 +327,22 @@ export type EmbedProgress = {
     totalChunks: number;
     bytesProcessed: number;
     totalBytes: number;
+    /** Chunks that were sent to the provider and came back without an embedding. */
     errors: number;
+    /**
+     * Chunks the run gave up on WITHOUT sending them (abort / expired session).
+     * Kept separate from `errors` so "we stopped early" can never be reported as
+     * "N chunks failed" — see `generateEmbeddings` (i-yghj098h).
+     */
+    skipped?: number;
 };
 export type EmbedResult = {
     docsProcessed: number;
     chunksEmbedded: number;
+    /** Attempted-and-failed chunks only. Never includes un-attempted ones. */
     errors: number;
+    /** Un-attempted chunks left behind by an early abort. */
+    skipped?: number;
     durationMs: number;
 };
 export type EmbedOptions = {
@@ -874,9 +884,8 @@ export interface HybridQueryOptions {
     /**
      * Optional embedding provider for query-side encoding (i-loazq6ze).
      * When supplied, the original-query vector AND any vec/hyde expansion
-     * variants are encoded through this provider (HTTP, GPU worker,
-     * AutoFallback chain) instead of `getLlm(store).embedBatch(...)`. Skip
-     * to keep pre-patch behavior (uses local LlamaCpp).
+     * variants are encoded through this commercial provider. Without one,
+     * learned work returns typed HOLD.
      */
     embedProvider?: EmbeddingProvider;
 }
@@ -919,8 +928,8 @@ export interface VectorSearchOptions {
     hooks?: Pick<SearchHooks, 'onExpand'>;
     /**
      * Optional embedding provider for query-side encoding (i-loazq6ze).
-     * When supplied, query vectors are encoded via the provider (HTTP /
-     * GPU worker / fallback chain) instead of the local llama-cpp model.
+     * When supplied, query vectors are encoded through the commercial API.
+     * Without one, learned work returns typed HOLD.
      */
     embedProvider?: EmbeddingProvider;
 }

+ 67 - 25
dist/store.js

@@ -1203,9 +1203,16 @@ export async function generateEmbeddings(store, options) {
     const result = await withEmbedSession(store, provider, async (session) => {
         let chunksEmbedded = 0;
         let errors = 0;
+        let skipped = 0;
         let bytesProcessed = 0;
         let totalChunks = 0;
         let vectorTableInitialized = false;
+        // Set once the run decides to stop early. The high-error-rate guard used to
+        // `break` the INNER batch loop only, so the very next document re-evaluated
+        // the same cumulative ratio and broke again — one transient 429 could book
+        // ~740k never-attempted chunks as "failures" across ~50 aborts
+        // (i-yghj098h). The flag stops the OUTER loop too, exactly once.
+        let abortRun = false;
         // Inner batch size — number of chunks fed into each `embedMany` call.
         // Bumped 32 → 256 (i-fkpnar9i) so the openai provider's concurrent
         // dispatcher receives ≥ 4 sub-chunks of size 64 (worker MAX_BATCH) and
@@ -1213,21 +1220,45 @@ export async function generateEmbeddings(store, options) {
         // Override per-deploy via `QMD_EMBED_INNER_BATCH_SIZE`.
         const BATCH_SIZE = parseInt(process.env.QMD_EMBED_INNER_BATCH_SIZE ?? "256", 10) || 256;
         const batches = buildEmbeddingBatches(docsToEmbed, maxDocsPerBatch, maxBatchBytes);
+        // Sliding error-rate window (i-yghj098h). The abort guard used to compare
+        // CUMULATIVE `errors` against CUMULATIVE processed, and fed its own
+        // un-attempted remainders back into `errors` — so once tripped the ratio
+        // could never fall back under the threshold and the run was poisoned for
+        // good. Judging only the last few ATTEMPTED batches means a burst of 429s
+        // that the provider then rides out (retry + lane cooldown) no longer
+        // condemns the rest of the run.
+        const ERROR_WINDOW_BATCHES = parseInt(process.env.QMD_EMBED_ERROR_WINDOW_BATCHES ?? "8", 10) || 8;
+        const ERROR_RATE_ABORT_THRESHOLD = 0.8;
+        const recentBatches = [];
+        const noteBatchOutcome = (attempted, failed) => {
+            if (attempted <= 0)
+                return;
+            recentBatches.push({ attempted, errors: failed });
+            while (recentBatches.length > ERROR_WINDOW_BATCHES)
+                recentBatches.shift();
+        };
+        const windowTotals = () => {
+            let attempted = 0;
+            let failed = 0;
+            for (const b of recentBatches) {
+                attempted += b.attempted;
+                failed += b.errors;
+            }
+            return { attempted, errors: failed };
+        };
         // Embedding helpers — single point of provider/session selection.
         // Both return the same shape as ILLMSession.embed/embedBatch so the
         // rest of the loop is unchanged.
         const embedOne = async (text, modelArg) => {
             if (provider) {
-                const sig = provider.kind === 'local' ? session.signal : undefined;
-                const r = await provider.embed(text, { model: modelArg, signal: sig });
+                const r = await provider.embed(text, { model: modelArg });
                 return r ? { embedding: r.embedding, model: r.model } : null;
             }
             return session.embed(text, { model: modelArg });
         };
         const embedMany = async (texts, modelArg) => {
             if (provider) {
-                const sig = provider.kind === 'local' ? session.signal : undefined;
-                const r = await provider.embedBatch(texts, { model: modelArg, signal: sig });
+                const r = await provider.embedBatch(texts, { model: modelArg });
                 return r.map((x) => (x ? { embedding: x.embedding, model: x.model } : null));
             }
             return session.embedBatch(texts, { model: modelArg });
@@ -1271,7 +1302,7 @@ export async function generateEmbeddings(store, options) {
             totalChunks += batchChunks.length;
             if (batchChunks.length === 0) {
                 bytesProcessed += batchBytes;
-                options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors });
+                options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors, skipped });
                 continue;
             }
             if (!vectorTableInitialized) {
@@ -1313,16 +1344,23 @@ export async function generateEmbeddings(store, options) {
                 // Abort early if session has been invalidated (e.g. max duration exceeded)
                 if (!session.isValid) {
                     const remaining = batchChunks.length - batchStart;
-                    errors += remaining;
+                    skipped += remaining;
+                    abortRun = true;
                     console.warn(`⚠ Session expired — skipping ${remaining} remaining chunks`);
                     break;
                 }
-                // Abort early if error rate is too high (>80% of processed chunks failed)
-                const processed = chunksEmbedded + errors;
-                if (processed >= BATCH_SIZE && errors > processed * 0.8) {
+                // Abort early if the RECENT attempted batches are overwhelmingly failing
+                // (>80% over the sliding window). Un-attempted chunks are booked as
+                // `skipped`, never as `errors`.
+                const window = windowTotals();
+                if (window.attempted >= BATCH_SIZE &&
+                    window.errors > window.attempted * ERROR_RATE_ABORT_THRESHOLD) {
                     const remaining = batchChunks.length - batchStart;
-                    errors += remaining;
-                    console.warn(`⚠ Error rate too high (${errors}/${processed}) — aborting embedding`);
+                    skipped += remaining;
+                    abortRun = true;
+                    console.warn(`⚠ Error rate too high (${window.errors}/${window.attempted} over last ` +
+                        `${recentBatches.length} batches) — aborting embedding ` +
+                        `(${remaining} chunks in this document left unattempted)`);
                     break;
                 }
                 const batchEnd = Math.min(batchStart + BATCH_SIZE, batchChunks.length);
@@ -1361,6 +1399,7 @@ export async function generateEmbeddings(store, options) {
                     const { okCount, errCount } = insertBatchTxn();
                     chunksEmbedded += okCount;
                     errors += errCount;
+                    noteBatchOutcome(chunkBatch.length, errCount);
                     batchChunkBytesProcessed += chunkBatch.reduce((sum, c) => sum + c.bytes, 0);
                 }
                 catch {
@@ -1368,9 +1407,11 @@ export async function generateEmbeddings(store, options) {
                     // But skip if session is already invalid (avoids N doomed retries)
                     if (!session.isValid) {
                         errors += chunkBatch.length;
+                        noteBatchOutcome(chunkBatch.length, chunkBatch.length);
                         batchChunkBytesProcessed += chunkBatch.reduce((sum, c) => sum + c.bytes, 0);
                     }
                     else {
+                        let fallbackErrors = 0;
                         for (const chunk of chunkBatch) {
                             try {
                                 const text = formatDocForEmbedding(chunk.text, chunk.title, embedModelUri);
@@ -1381,13 +1422,16 @@ export async function generateEmbeddings(store, options) {
                                 }
                                 else {
                                     errors++;
+                                    fallbackErrors++;
                                 }
                             }
                             catch {
                                 errors++;
+                                fallbackErrors++;
                             }
                             batchChunkBytesProcessed += chunk.bytes;
                         }
+                        noteBatchOutcome(chunkBatch.length, fallbackErrors);
                     }
                 }
                 const proportionalBytes = totalBatchChunkBytes === 0
@@ -1399,17 +1443,22 @@ export async function generateEmbeddings(store, options) {
                     bytesProcessed: bytesProcessed + proportionalBytes,
                     totalBytes,
                     errors,
+                    skipped,
                 });
             }
             bytesProcessed += batchBytes;
-            options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors });
+            options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors, skipped });
+            // One abort ends the run — it must not re-trip on every remaining doc.
+            if (abortRun)
+                break;
         }
-        return { chunksEmbedded, errors };
+        return { chunksEmbedded, errors, skipped };
     }, { maxDuration: 30 * 60 * 1000, name: 'generateEmbeddings' });
     return {
         docsProcessed: totalDocs,
         chunksEmbedded: result.chunksEmbedded,
         errors: result.errors,
+        skipped: result.skipped,
         durationMs: Date.now() - startTime,
     };
 }
@@ -2742,11 +2791,7 @@ async function getEmbedding(text, model, isQuery, session, llmOverride, embedPro
         const formattedText = isQuery
             ? formatQueryForEmbedding(text, providerModel)
             : formatDocForEmbedding(text, undefined, providerModel);
-        // Only forward an AbortSignal when the provider is local-backed;
-        // remote providers manage their own timeouts and an LLM-session signal
-        // would abort their HTTP request prematurely (i-08ovbvtb).
-        const sig = embedProvider.kind === "local" ? session?.signal : undefined;
-        const result = await embedProvider.embed(formattedText, sig ? { model: providerModel, signal: sig } : { model: providerModel });
+        const result = await embedProvider.embed(formattedText, { model: providerModel });
         return result?.embedding ?? null;
     }
     // Format text using the appropriate prompt template
@@ -3479,9 +3524,8 @@ export async function hybridQuery(store, query, options) {
             }
         }
         // Batch embed all vector queries in a single call.
-        // When `embedProvider` is supplied (i-loazq6ze), route the encode through
-        // it (HTTP / GPU worker / AutoFallback chain) instead of warming the
-        // local llama-cpp model — this is the whole point of the GPU worker.
+        // Route query embeddings through the injected commercial provider. The
+        // compatibility path below is fail-closed and returns typed HOLD.
         const embedModelName = embedProvider
             ? embedProvider.getModelId()
             : getLlm(store).embedModelName;
@@ -3802,10 +3846,8 @@ export async function structuredSearch(store, searches, options) {
     if (hasVectors) {
         const vecSearches = searches.filter((s) => s.type === 'vec' || s.type === 'hyde');
         if (vecSearches.length > 0) {
-            // Route batch encoding through the supplied EmbeddingProvider when
-            // present (i-loazq6ze). Otherwise fall back to the local llama-cpp
-            // singleton — preserves pre-patch behavior for callers that don't
-            // configure a provider.
+            // Route batch encoding through the supplied commercial provider. The
+            // compatibility branch returns typed HOLD when no provider is configured.
             const embedModelName = embedProvider
                 ? embedProvider.getModelId()
                 : getLlm(store).embedModelName;

+ 5 - 5
src/cli/qmd.ts

@@ -1712,13 +1712,13 @@ function buildProviderOpts(
         }
       : undefined;
 
-  // Historical flag is passed through so the factory can reject it as typed HOLD.
-  const autoFallback = values["embed-auto-fallback"] === true ? true : undefined;
+  if (values["embed-auto-fallback"] === true) {
+    throw commercialApiHold("embedding auto-fallback is forbidden");
+  }
 
   return {
     ...(providerCliKind ? { kind: providerCliKind } : {}),
     ...(openai ? { openai } : {}),
-    ...(autoFallback !== undefined ? { autoFallback } : {}),
   };
 }
 
@@ -2600,14 +2600,14 @@ function parseCLI() {
       force: { type: "boolean", short: "f" },
       "max-docs-per-batch": { type: "string" },
       "max-batch-mb": { type: "string" },
-      provider: { type: "string" },                  // "local" | "openai"
+      provider: { type: "string" },                  // commercial OpenAI-compatible API
       "embed-endpoint": { type: "string" },          // OpenAI-compatible endpoint URL
       "embed-api-key": { type: "string" },           // Bearer token
       "embed-model-id": { type: "string" },          // Stable model id (default: embeddinggemma)
       "embed-upstream-model": { type: "string" },    // Upstream model name in HTTP body
       "embed-batch-size": { type: "string" },        // Batch size for HTTP provider
       "embed-timeout-ms": { type: "string" },        // Per-request timeout
-      "embed-auto-fallback": { type: "boolean" },    // Wrap openai in AutoFallback (local fallback)
+      "embed-auto-fallback": { type: "boolean" },    // forbidden compatibility flag
       "no-vacuum": { type: "boolean" },              // cleanup: skip VACUUM for cron-safe pruning
       // Update options
       pull: { type: "boolean" },  // git pull before update

+ 8 - 10
src/embedding/autofallback.ts

@@ -1,13 +1,8 @@
 /**
  * autofallback.ts - AutoFallbackEmbeddingProvider.
  *
- * Composes a primary `EmbeddingProvider` (typically `OpenAIEmbeddingsProvider`)
- * and a fallback (typically `LocalLlamaCppProvider`). When the primary trips
- * its circuit breaker — or when persistent failures cross a threshold — calls
- * are routed to the fallback. After a recovery cooldown, the primary is
- * probed again; success closes the breaker and routing returns.
- *
- * Acceptance criterion 4 from i-qkarfffa: "Endpoint down → fallback local + WARN".
+ * Historical internal helper for two commercial API providers. It is not
+ * exported by the package or selected by the factory.
  *
  * Behavior summary:
  *   - Primary call succeeds → return; record success.
@@ -30,6 +25,7 @@ import type {
   ProviderKind,
 } from "./provider.js";
 import { CircuitOpenError } from "./openai.js";
+import { commercialApiHold } from "../model-policy.js";
 
 export type AutoFallbackProviderConfig = {
   primary: EmbeddingProvider;
@@ -76,6 +72,9 @@ export class AutoFallbackEmbeddingProvider implements EmbeddingProvider {
   private lastTransitionState: FallbackState = "primary";
 
   constructor(config: AutoFallbackProviderConfig) {
+    throw commercialApiHold(
+      "automatic provider fallback is disabled; commercial API failures must remain HOLD",
+    );
     if (!config.primary) throw new Error("AutoFallbackEmbeddingProvider: primary is required");
     if (!config.fallback) throw new Error("AutoFallbackEmbeddingProvider: fallback is required");
     if (config.primary === config.fallback) {
@@ -95,9 +94,8 @@ export class AutoFallbackEmbeddingProvider implements EmbeddingProvider {
   /**
    * Stable model id reported by the primary. The model-id guard runs against
    * the primary's id because that's what callers actually want when the
-   * remote endpoint is online; on fallback-only operation, the local
-   * provider should report a compatible id (in the default config, both
-   * report "embeddinggemma" so this is moot).
+   * remote endpoint is online; a secondary commercial provider must report a
+   * compatible id.
    */
   getModelId(): string {
     return this.primary.getModelId();

+ 22 - 25
src/embedding/factory.ts

@@ -36,8 +36,6 @@ export type EmbedProviderConfigFile = {
      */
     concurrency?: number;
     timeoutMs?: number;
-    /** Historical only. `true` is rejected because local fallback is forbidden. */
-    autoFallback?: boolean;
   };
 };
 
@@ -66,17 +64,12 @@ export function loadConfigFile(path: string = defaultConfigPath()): EmbedProvide
 // ─────────────────────────── Factory options ────────────────────────────────
 
 export type CreateEmbeddingProviderOptions = {
-  /** Force a specific provider kind. Overrides env + config. */
+  /** Force the commercial provider kind. Overrides env + config. */
   kind?: ProviderKind;
   /** Override config file path (mostly for tests) */
   configPath?: string;
   /** OpenAI-provider overrides — merged on top of env/config */
   openai?: Partial<OpenAIProviderConfig>;
-  /**
-   * Historical compatibility input. Any truthy value produces typed HOLD;
-   * commercial provider failures must never fall back to a local model.
-   */
-  autoFallback?: boolean;
   /**
    * Custom env source (mostly for tests). Defaults to `process.env`.
    * Read keys: QMD_EMBED_PROVIDER, QMD_EMBED_ENDPOINT, QMD_EMBED_API_KEY,
@@ -95,10 +88,14 @@ export function resolveProviderKind(opts: CreateEmbeddingProviderOptions = {}):
   const cfg = loadConfigFile(opts.configPath);
 
   // 1. Explicit kind argument
-  if (opts.kind === "local") {
+  const explicitKind = opts.kind as string | undefined;
+  if (explicitKind === "local") {
     throw commercialApiHold('provider kind "local" is disabled; configure an approved commercial API');
   }
-  if (opts.kind === "openai") return opts.kind;
+  if (explicitKind === "openai") return "openai";
+  if (explicitKind) {
+    throw commercialApiHold(`unsupported commercial provider kind "${explicitKind}"`);
+  }
 
   // 2a. Explicit env override
   const envKind = env.QMD_EMBED_PROVIDER?.trim().toLowerCase();
@@ -106,6 +103,9 @@ export function resolveProviderKind(opts: CreateEmbeddingProviderOptions = {}):
     throw commercialApiHold("QMD_EMBED_PROVIDER=local is forbidden");
   }
   if (envKind === "openai") return envKind;
+  if (envKind) {
+    throw commercialApiHold(`unsupported QMD_EMBED_PROVIDER=${envKind}`);
+  }
 
   // 2b. Endpoint env present → openai
   if (env.QMD_EMBED_ENDPOINT && env.QMD_EMBED_ENDPOINT.trim() !== "") {
@@ -113,12 +113,16 @@ export function resolveProviderKind(opts: CreateEmbeddingProviderOptions = {}):
   }
 
   // 3. Config file
-  if (cfg.embedProvider?.kind === "local") {
+  const configKind = cfg.embedProvider?.kind as string | undefined;
+  if (configKind === "local") {
     throw commercialApiHold("embedProvider.kind=local is forbidden");
   }
-  if (cfg.embedProvider?.kind === "openai") {
+  if (configKind === "openai") {
     return "openai";
   }
+  if (configKind) {
+    throw commercialApiHold(`unsupported embedProvider.kind=${configKind}`);
+  }
   if (cfg.embedProvider?.endpoint && cfg.embedProvider.endpoint.trim() !== "") {
     return "openai";
   }
@@ -136,11 +140,7 @@ export function createEmbeddingProvider(
 ): EmbeddingProvider {
   const env = opts.env ?? process.env;
   const cfg = loadConfigFile(opts.configPath);
-  const kind = resolveProviderKind(opts);
-
-  if (kind === "local") {
-    throw commercialApiHold('provider kind "local" is disabled');
-  }
+  resolveProviderKind(opts);
 
   // OpenAI
   const endpoint =
@@ -202,7 +202,7 @@ export function createEmbeddingProvider(
   });
 
   // Historical fallback inputs are rejected instead of silently weakening policy.
-  const autoFallback = resolveAutoFallback(opts, env, cfg);
+  const autoFallback = resolveAutoFallback(env, cfg);
   if (autoFallback) {
     throw commercialApiHold("local auto-fallback is forbidden; commercial API failures must remain HOLD");
   }
@@ -229,18 +229,15 @@ export function assertCommercialEndpoint(endpoint: string): void {
 }
 
 function resolveAutoFallback(
-  opts: CreateEmbeddingProviderOptions,
   env: Record<string, string | undefined>,
-  cfg: EmbedProviderConfigFile & {
-    embedProvider?: { autoFallback?: boolean };
-  },
+  cfg: EmbedProviderConfigFile,
 ): boolean {
-  if (typeof opts.autoFallback === "boolean") return opts.autoFallback;
   const envVal = env.QMD_EMBED_AUTO_FALLBACK?.trim().toLowerCase();
   if (envVal === "1" || envVal === "true" || envVal === "yes") return true;
   if (envVal === "0" || envVal === "false" || envVal === "no") return false;
-  if (typeof cfg.embedProvider?.autoFallback === "boolean") {
-    return cfg.embedProvider.autoFallback;
+  const configAutoFallback = (cfg.embedProvider as { autoFallback?: unknown } | undefined)?.autoFallback;
+  if (typeof configAutoFallback === "boolean") {
+    return configAutoFallback;
   }
   return false;
 }

+ 0 - 11
src/embedding/index.ts

@@ -12,11 +12,6 @@ export {
   assertModelCompatible,
 } from "./provider.js";
 
-export {
-  LocalLlamaCppProvider,
-  type LocalLlamaCppProviderConfig,
-} from "./local.js";
-
 export {
   OpenAIEmbeddingsProvider,
   CircuitBreaker,
@@ -40,9 +35,3 @@ export {
   type CreateEmbeddingProviderOptions,
   type EmbedProviderConfigFile,
 } from "./factory.js";
-
-export {
-  AutoFallbackEmbeddingProvider,
-  type AutoFallbackProviderConfig,
-  type FallbackState,
-} from "./autofallback.js";

+ 2 - 25
src/embedding/local.ts

@@ -1,34 +1,11 @@
 import { commercialApiHold } from "../model-policy.js";
 import type { LlamaCpp } from "../llm.js";
-import type {
-  EmbeddingProvider,
-  ProviderEmbedOptions,
-  ProviderEmbedding,
-  ProviderHealth,
-  ProviderKind,
-} from "./provider.js";
 
 export type LocalLlamaCppProviderConfig = { llm?: LlamaCpp; modelId?: string };
 
-/** Historical SDK symbol. Construction is blocked by the commercial-only policy. */
-export class LocalLlamaCppProvider implements EmbeddingProvider {
-  readonly kind: ProviderKind = "local";
-
+/** Historical direct-import symbol. It is not part of the package API. */
+export class LocalLlamaCppProvider {
   constructor(_config: LocalLlamaCppProviderConfig = {}) {
     throw commercialApiHold("LocalLlamaCppProvider is disabled; configure an approved commercial API");
   }
-
-  getModelId(): string { return "disabled-local-provider"; }
-  getDimensions(): number | undefined { return undefined; }
-  getLastError(): string | undefined { return "QMD_COMMERCIAL_API_HOLD"; }
-  async healthcheck(_signal?: AbortSignal): Promise<ProviderHealth> {
-    throw commercialApiHold("local provider healthcheck is disabled");
-  }
-  async embed(_text: string, _options: ProviderEmbedOptions = {}): Promise<ProviderEmbedding | null> {
-    throw commercialApiHold("local embedding is disabled");
-  }
-  async embedBatch(_texts: string[], _options: ProviderEmbedOptions = {}): Promise<(ProviderEmbedding | null)[]> {
-    throw commercialApiHold("local batch embedding is disabled");
-  }
-  async dispose(): Promise<void> {}
 }

+ 299 - 18
src/embedding/openai.ts

@@ -10,9 +10,16 @@
  * Features:
  *   - Batches input in groups of ≤64 (configurable via QMD_EMBED_BATCH_SIZE)
  *   - Retries 429 / 503 with exponential backoff (1s, 4s, 16s)
+ *   - 429 gets its own, larger retry budget and honours `Retry-After`
+ *     (header or `"Retry after 29s"` body text) — a shared gateway bucket
+ *     refilling in 30s must not exhaust a 1s/4s/16s schedule (i-yghj098h)
+ *   - Bulk lane (`embedBatch` with >1 input): a 429 pauses the WHOLE worker
+ *     pool for the advertised cooldown and halves in-flight concurrency
+ *     (AIMD), so reindex traffic yields the bucket to interactive callers
+ *     instead of contending head-on with them
  *   - 4xx (non-429) → no retry, count as failure
- *   - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min,
- *     callers receive failures; local fallback is forbidden
+ *   - Circuit breaker: >50% failures in a 60s window → OPEN for 5 min;
+ *     callers receive failures and no model fallback is selected
  *   - Per-call timeout via AbortSignal (default QMD_EMBED_TIMEOUT_MS=30000)
  *   - Healthcheck via `GET /health` if available, else a probe embed call
  */
@@ -57,6 +64,31 @@ export const DEFAULT_TIMEOUT_MS = 30_000;
  */
 export const RETRY_BACKOFFS_MS: readonly number[] = [1_000, 4_000, 16_000];
 
+/**
+ * Rate-limit (429) retry budget, separate from `RETRY_BACKOFFS_MS`.
+ *
+ * A 429 is backpressure, not a fault: the shared ai.mm.mk gateway answers
+ * `{"detail":"Rate limit exceeded (tokens). Retry after 29s."}` with observed
+ * retry-after values of 7s/29s/30s, while the generic schedule waits at most
+ * 1+4+16 = 21s in total. Bulk reindex runs therefore burned all three attempts
+ * inside one bucket refill and reported the chunk as failed (i-yghj098h).
+ * 429s get their own attempt count and always wait at least as long as the
+ * server asked for, capped by `RATE_LIMIT_MAX_BACKOFF_MS`.
+ */
+export const DEFAULT_RATE_LIMIT_RETRIES = 5;
+
+/** Upper bound on a single rate-limit wait, so a bogus Retry-After can't hang a run. */
+export const RATE_LIMIT_MAX_BACKOFF_MS = 60_000;
+
+/** Fallback wait when a 429 carries no parseable Retry-After (doubles per attempt). */
+export const RATE_LIMIT_BASE_BACKOFF_MS = 5_000;
+
+/**
+ * Consecutive successful bulk requests before the lane additively recovers one
+ * unit of concurrency after a 429-triggered halving (AIMD).
+ */
+export const LANE_RECOVERY_STREAK = 8;
+
 /**
  * Circuit breaker — flips OPEN when error rate exceeds threshold within
  * window. While OPEN, every call fails fast so the caller can fall back.
@@ -97,6 +129,12 @@ export type OpenAIProviderConfig = {
   fetchImpl?: typeof fetch;
   /** Custom retry schedule (for testing). Defaults to RETRY_BACKOFFS_MS. */
   retryBackoffsMs?: readonly number[];
+  /**
+   * Extra retry attempts granted to 429 responses on top of the generic
+   * schedule (default DEFAULT_RATE_LIMIT_RETRIES = 5, env
+   * `QMD_EMBED_RATE_LIMIT_RETRIES`). Rate limits are backpressure, not faults.
+   */
+  rateLimitRetries?: number;
   /** Custom sleep impl (for testing). Defaults to setTimeout. */
   sleep?: (ms: number) => Promise<void>;
   /** Custom clock (for testing). Defaults to Date.now. */
@@ -122,12 +160,27 @@ export type OpenAIEmbeddingsResponse = {
  */
 export type CircuitState = "closed" | "open" | "half-open";
 
+/**
+ * Traffic lane for a single `embedBatch` call. `bulk` = reindex traffic
+ * (multi-input batch), which self-throttles via `BulkLaneGate` and is
+ * attributed separately in `X-AI-Caller`. `interactive` = query-time embeds,
+ * which are never made to wait behind a bulk cooldown.
+ */
+export type EmbedLane = "interactive" | "bulk";
+
 // ─────────────────────────── Helpers ─────────────────────────────────────────
 
 function defaultSleep(ms: number): Promise<void> {
   return new Promise((resolve) => setTimeout(resolve, ms));
 }
 
+/** Parse a non-negative integer env value; `undefined` when unset/invalid. */
+function parseNonNegativeInt(raw: string | undefined): number | undefined {
+  if (raw == null || raw.trim() === "") return undefined;
+  const n = Number.parseInt(raw, 10);
+  return Number.isFinite(n) && n >= 0 ? n : undefined;
+}
+
 /**
  * Build the advisory `X-AI-Caller` attribution header value (Oivo ai.mm.mk
  * "Observability-Driven Fleet Self-Improvement" rollout, Thread 1). Format
@@ -208,6 +261,51 @@ export function isRetryableStatus(status: number): boolean {
   return status === 429 || status === 503;
 }
 
+/**
+ * Extract the server-advertised cooldown from a rate-limited response.
+ *
+ * Two sources, in priority order:
+ *   1. the standard `Retry-After` header — delta-seconds or an HTTP-date;
+ *   2. the ai.mm.mk body text, which states the cooldown in prose only:
+ *      `{"detail":"Rate limit exceeded (tokens). Retry after 29s.", ...}`.
+ *
+ * Returns `undefined` when neither source yields a sane positive duration, so
+ * the caller falls back to its own schedule. Values are clamped to
+ * `RATE_LIMIT_MAX_BACKOFF_MS`.
+ */
+export function parseRetryAfterMs(
+  headerValue: string | null | undefined,
+  bodyPreview?: string,
+  now: () => number = Date.now,
+): number | undefined {
+  const clamp = (ms: number): number | undefined =>
+    Number.isFinite(ms) && ms > 0 ? Math.min(ms, RATE_LIMIT_MAX_BACKOFF_MS) : undefined;
+
+  const header = headerValue?.trim();
+  if (header) {
+    if (/^\d+(\.\d+)?$/.test(header)) {
+      const fromSeconds = clamp(Number(header) * 1000);
+      if (fromSeconds !== undefined) return fromSeconds;
+    }
+    const asDate = Date.parse(header);
+    if (!Number.isNaN(asDate)) {
+      const fromDate = clamp(asDate - now());
+      if (fromDate !== undefined) return fromDate;
+    }
+  }
+
+  if (bodyPreview) {
+    const m = /retry\s+after\s+(\d+(?:\.\d+)?)\s*(ms|s|seconds?)?/i.exec(bodyPreview);
+    if (m) {
+      const value = Number(m[1]);
+      const unit = (m[2] ?? "s").toLowerCase();
+      return clamp(unit === "ms" ? value : value * 1000);
+    }
+  }
+
+  return undefined;
+}
+
 /**
  * Chunk an array into pieces of ≤ size each. `size` MUST be ≥ 1.
  */
@@ -335,7 +433,6 @@ export class CircuitBreaker {
 
 /**
  * Raised when the circuit breaker is OPEN and a call is short-circuited.
- * Callers (e.g. fallback wrapper) can catch this to switch to local provider.
  */
 export class CircuitOpenError extends Error {
   constructor(message = "OpenAIEmbeddingsProvider circuit is OPEN") {
@@ -350,11 +447,115 @@ export class CircuitOpenError extends Error {
 export class HttpError extends Error {
   readonly status: number;
   readonly bodyPreview: string;
-  constructor(status: number, bodyPreview: string) {
+  /** Server-advertised cooldown for 429s, when the response stated one. */
+  readonly retryAfterMs?: number;
+  constructor(status: number, bodyPreview: string, retryAfterMs?: number) {
     super(`HTTP ${status}: ${bodyPreview.slice(0, 200)}`);
     this.name = "HttpError";
     this.status = status;
     this.bodyPreview = bodyPreview.slice(0, 1024);
+    this.retryAfterMs = retryAfterMs;
+  }
+}
+
+// ─────────────────────────── Bulk lane ───────────────────────────────────────
+
+/**
+ * Client-side bulk lane for reindex traffic (i-yghj098h).
+ *
+ * qmd's bulk embedding shares the interactive ai.mm.mk token bucket. Without a
+ * server-side per-caller budget, the only way bulk traffic can stop contending
+ * head-on with interactive callers is to police itself:
+ *
+ *   - a 429 on ANY worker pauses the ENTIRE pool for the advertised cooldown
+ *     (one shared promise — concurrent 429s coalesce instead of stacking N
+ *     cooldowns), so the bucket refills for interactive callers rather than
+ *     being re-drained by the remaining workers;
+ *   - the in-flight cap halves on each cooldown (floor 1) and recovers one unit
+ *     per `LANE_RECOVERY_STREAK` successes — classic AIMD, so a run settles at
+ *     whatever share the bucket actually has spare.
+ *
+ * Deliberately clock-free: cooldowns are modelled as a promise produced by the
+ * injected `sleep`, so tests drive them with a fake sleep and no fake clock.
+ */
+export class BulkLaneGate {
+  private readonly maxPermits: number;
+  private readonly sleep: (ms: number) => Promise<void>;
+  private permits: number;
+  private inFlight = 0;
+  private okStreak = 0;
+  private cooldown: Promise<void> | null = null;
+  private waiters: (() => void)[] = [];
+
+  constructor(maxPermits: number, sleep: (ms: number) => Promise<void>) {
+    this.maxPermits = Math.max(1, maxPermits);
+    this.permits = this.maxPermits;
+    this.sleep = sleep;
+  }
+
+  /** Current in-flight cap — exported state for tests/diagnostics. */
+  get permitCount(): number {
+    return this.permits;
+  }
+
+  /** True while the lane is serving a rate-limit cooldown. */
+  get isCoolingDown(): boolean {
+    return this.cooldown !== null;
+  }
+
+  /** Take a slot, waiting out any cooldown and respecting the current cap. */
+  async acquire(): Promise<void> {
+    while (this.cooldown !== null || this.inFlight >= this.permits) {
+      if (this.cooldown !== null) {
+        await this.cooldown;
+        continue;
+      }
+      await new Promise<void>((resolve) => this.waiters.push(resolve));
+    }
+    this.inFlight++;
+  }
+
+  /** Return a slot. Always call from a `finally`. */
+  release(): void {
+    if (this.inFlight > 0) this.inFlight--;
+    this.wakeOne();
+  }
+
+  /**
+   * Enter (or join) a cooldown of `waitMs` and halve the in-flight cap.
+   * Returns the shared cooldown promise — the caller awaits it INSTEAD of
+   * sleeping itself, so a burst of 429s costs one cooldown, not one each.
+   */
+  penalize(waitMs: number): Promise<void> {
+    if (this.cooldown !== null) return this.cooldown;
+    this.okStreak = 0;
+    this.permits = Math.max(1, Math.floor(this.permits / 2));
+    const cooldown = this.sleep(waitMs).then(() => {
+      if (this.cooldown === cooldown) this.cooldown = null;
+      this.wakeAll();
+    });
+    this.cooldown = cooldown;
+    return cooldown;
+  }
+
+  /** Record a successful bulk request; recovers one permit per success streak. */
+  noteSuccess(): void {
+    if (this.permits >= this.maxPermits) return;
+    if (++this.okStreak >= LANE_RECOVERY_STREAK) {
+      this.okStreak = 0;
+      this.permits++;
+      this.wakeOne();
+    }
+  }
+
+  private wakeOne(): void {
+    this.waiters.shift()?.();
+  }
+
+  private wakeAll(): void {
+    const pending = this.waiters;
+    this.waiters = [];
+    for (const resolve of pending) resolve();
   }
 }
 
@@ -372,12 +573,15 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
   private readonly timeoutMs: number;
   private readonly fetchImpl: typeof fetch;
   private readonly retryBackoffsMs: readonly number[];
+  private readonly rateLimitRetries: number;
   private readonly sleep: (ms: number) => Promise<void>;
   private readonly now: () => number;
 
   private dimensions: number | undefined = undefined;
   private lastError: string | undefined = undefined;
   readonly breaker: CircuitBreaker;
+  /** Shared bulk-traffic lane — see `BulkLaneGate`. */
+  readonly lane: BulkLaneGate;
 
   constructor(config: OpenAIProviderConfig) {
     if (!config.endpoint) {
@@ -392,9 +596,14 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
     this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
     this.fetchImpl = config.fetchImpl ?? globalThis.fetch;
     this.retryBackoffsMs = config.retryBackoffsMs ?? RETRY_BACKOFFS_MS;
+    this.rateLimitRetries =
+      config.rateLimitRetries ??
+      parseNonNegativeInt(process.env.QMD_EMBED_RATE_LIMIT_RETRIES) ??
+      DEFAULT_RATE_LIMIT_RETRIES;
     this.sleep = config.sleep ?? defaultSleep;
     this.now = config.now ?? Date.now;
     this.breaker = new CircuitBreaker({ now: this.now });
+    this.lane = new BulkLaneGate(this.concurrency, this.sleep);
 
     if (!this.fetchImpl) {
       throw new Error(
@@ -507,6 +716,12 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
       throw new CircuitOpenError();
     }
 
+    // Bulk = anything that isn't a single query-time embed. Reindex runs go
+    // through the self-throttling lane and are attributed as `embeddings-bulk`;
+    // `embed()` (search path) stays interactive and is never parked behind a
+    // bulk cooldown (i-yghj098h).
+    const lane: EmbedLane = texts.length > 1 ? "bulk" : "interactive";
+
     const chunks = chunkArray(texts, this.batchSize);
     const results: (ProviderEmbedding | null)[] = new Array(texts.length).fill(null);
 
@@ -561,7 +776,14 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
         }
 
         try {
-          const embeddings = await this.requestWithRetry(chunk, options);
+          if (lane === "bulk") await this.lane.acquire();
+          let embeddings: number[][];
+          try {
+            embeddings = await this.requestWithRetry(chunk, options, lane);
+          } finally {
+            if (lane === "bulk") this.lane.release();
+          }
+          if (lane === "bulk") this.lane.noteSuccess();
           for (let i = 0; i < chunk.length; i++) {
             const embedding = embeddings[i];
             if (embedding) {
@@ -580,7 +802,12 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
           }
           this.breaker.recordSuccess();
         } catch (err) {
-          this.breaker.recordFailure();
+          // A rate limit is backpressure, not a fault. Counting 429s toward the
+          // breaker turns "the bucket is empty for 30s" into a 5-minute hard
+          // OPEN, which is the outage the retry budget above exists to avoid.
+          // The lane cooldown is already the correct control response.
+          const rateLimited = err instanceof HttpError && err.status === 429;
+          if (!rateLimited) this.breaker.recordFailure();
           if (err instanceof CircuitOpenError) {
             circuitTrippedDuringRun = err;
             return;
@@ -639,13 +866,18 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
     return `endpoint=${this.endpoint}/v1/embeddings error="${String(err)}"`;
   }
 
-  private buildHeaders(): Record<string, string> {
+  private buildHeaders(lane: EmbedLane = "interactive"): Record<string, string> {
     const headers: Record<string, string> = {
       "Content-Type": "application/json",
       "Accept": "application/json",
       // Advisory caller attribution for the ai.mm.mk gateway (NULL-safe, never
       // auth). Single chokepoint for both /health and /v1/embeddings.
-      "X-AI-Caller": aiCallerHeaderValue("embeddings"),
+      // `via` distinguishes bulk reindex traffic from query-time embeds so the
+      // gateway can budget the two separately (i-yghj098h part 1b) — the
+      // gateway-side per-caller bucket keys off this label.
+      "X-AI-Caller": aiCallerHeaderValue(
+        lane === "bulk" ? "embeddings-bulk" : "embeddings",
+      ),
     };
     if (this.apiKey) {
       headers["Authorization"] = `Bearer ${this.apiKey}`;
@@ -661,26 +893,45 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
   private async requestWithRetry(
     texts: string[],
     options: ProviderEmbedOptions,
+    lane: EmbedLane = "interactive",
   ): Promise<number[][]> {
     let lastErr: unknown = null;
-    const maxAttempts = this.retryBackoffsMs.length + 1;
+    // 429s draw on their own budget: a token bucket that refills in 30s must
+    // not be able to exhaust a schedule whose total wait is 21s (i-yghj098h).
+    let backoffAttempt = 0;
+    let rateLimitAttempt = 0;
 
-    for (let attempt = 0; attempt < maxAttempts; attempt++) {
+    for (;;) {
       // Honor user abort BEFORE issuing the call (avoids wasted network)
       if (options.signal?.aborted) {
         throw new Error("aborted by caller");
       }
 
       try {
-        return await this.requestOnce(texts, options);
+        return await this.requestOnce(texts, options, lane);
       } catch (err) {
         lastErr = err;
-        const retryable =
-          err instanceof HttpError ? isRetryableStatus(err.status) : false;
-        if (!retryable) throw err;
-        if (attempt < this.retryBackoffsMs.length) {
-          await this.sleep(this.retryBackoffsMs[attempt]!);
+        const status = err instanceof HttpError ? err.status : 0;
+        if (!isRetryableStatus(status)) throw err;
+
+        if (status === 429) {
+          if (rateLimitAttempt >= this.rateLimitRetries) throw err;
+          const waitMs = this.rateLimitWaitMs(err as HttpError, rateLimitAttempt);
+          rateLimitAttempt++;
+          if (lane === "bulk") {
+            // Pause the whole pool, not just this worker: the other workers
+            // hammering the same empty bucket is exactly what turns one 429
+            // into a cascade, and what starves interactive callers.
+            await this.lane.penalize(waitMs);
+          } else {
+            await this.sleep(waitMs);
+          }
+          continue;
         }
+
+        if (backoffAttempt >= this.retryBackoffsMs.length) break;
+        await this.sleep(this.retryBackoffsMs[backoffAttempt]!);
+        backoffAttempt++;
       }
     }
 
@@ -688,12 +939,32 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
     throw lastErr ?? new Error("requestWithRetry exhausted");
   }
 
+  /**
+   * How long to wait after a 429. The server's own `Retry-After` wins when it
+   * gave one; otherwise fall back to the configured schedule (so injected test
+   * schedules stay authoritative) and then to an exponential 5s/10s/20s… ramp.
+   * Always clamped to `RATE_LIMIT_MAX_BACKOFF_MS`.
+   */
+  private rateLimitWaitMs(err: HttpError, rateLimitAttempt: number): number {
+    const scheduled =
+      this.retryBackoffsMs[
+        Math.min(rateLimitAttempt, this.retryBackoffsMs.length - 1)
+      ];
+    const fallback =
+      scheduled ?? RATE_LIMIT_BASE_BACKOFF_MS * Math.pow(2, rateLimitAttempt);
+    return Math.min(
+      Math.max(err.retryAfterMs ?? 0, fallback),
+      RATE_LIMIT_MAX_BACKOFF_MS,
+    );
+  }
+
   /**
    * Issue one HTTP attempt to `POST /v1/embeddings`. Does NOT retry.
    */
   private async requestOnce(
     texts: string[],
     options: ProviderEmbedOptions,
+    lane: EmbedLane = "interactive",
   ): Promise<number[][]> {
     const { signal: attemptSig, cleanup } = buildAttemptSignal(options.signal, this.timeoutMs);
     try {
@@ -703,14 +974,24 @@ export class OpenAIEmbeddingsProvider implements EmbeddingProvider {
       });
       const resp = await this.fetchImpl(`${this.endpoint}/v1/embeddings`, {
         method: "POST",
-        headers: this.buildHeaders(),
+        headers: this.buildHeaders(lane),
         body,
         signal: attemptSig,
       });
 
       if (!resp.ok) {
         const text = await resp.text().catch(() => "");
-        throw new HttpError(resp.status, text);
+        // ai.mm.mk states the cooldown in the JSON body, not always in a
+        // Retry-After header — read both (i-yghj098h).
+        const retryAfterMs =
+          resp.status === 429
+            ? parseRetryAfterMs(
+                resp.headers?.get?.("retry-after"),
+                text,
+                this.now,
+              )
+            : undefined;
+        throw new HttpError(resp.status, text, retryAfterMs);
       }
 
       let parsed: OpenAIEmbeddingsResponse;

+ 4 - 4
src/embedding/provider.ts

@@ -1,8 +1,8 @@
 /**
  * provider.ts - Embedding provider abstraction
  *
- * Production embeddings use a commercial OpenAI-compatible API. The `local`
- * kind remains readable only for historical config and is rejected by the factory.
+ * Production embeddings use a commercial OpenAI-compatible API. Historical
+ * local configuration is parsed only by the factory so it can fail closed.
  *
  * The factory in `./factory.ts` selects an implementation based on env vars,
  * a CLI flag, or `~/.config/qmd/config.json`.
@@ -20,7 +20,7 @@ export type ProviderEmbedding = {
 /**
  * Supported provider kinds
  */
-export type ProviderKind = "local" | "openai";
+export type ProviderKind = "openai";
 
 /**
  * Healthcheck result for provider startup verification
@@ -112,7 +112,7 @@ export interface EmbeddingProvider {
    */
   getLastError?(): string | undefined;
 
-  /** Release any held resources (HTTP keep-alive sockets, model handles, …) */
+  /** Release any held resources, such as HTTP keep-alive sockets. */
   dispose(): Promise<void>;
 }
 

+ 15 - 18
src/index.ts

@@ -128,13 +128,12 @@ export { Maintenance } from "./maintenance.js";
 import type { EmbeddingProvider } from "./embedding/index.js";
 
 // Re-export embedding provider abstraction for SDK consumers (i-qkarfffa).
-// `createEmbeddingProvider` is commercial-only. The historical local symbol
-// remains exported for source compatibility but its constructor returns HOLD.
+// `createEmbeddingProvider` is commercial-only. Local and fallback providers
+// are intentionally absent from the package API.
 export {
   createEmbeddingProvider,
   resolveProviderKind,
   assertCommercialEndpoint,
-  LocalLlamaCppProvider,
   OpenAIEmbeddingsProvider,
   CircuitBreaker,
   CircuitOpenError,
@@ -148,7 +147,6 @@ export {
   type ProviderHealth,
   type CreateEmbeddingProviderOptions,
   type OpenAIProviderConfig,
-  type LocalLlamaCppProviderConfig,
   type EmbedProviderConfigFile,
   DEFAULT_BATCH_SIZE as DEFAULT_PROVIDER_BATCH_SIZE,
   DEFAULT_TIMEOUT_MS as DEFAULT_PROVIDER_TIMEOUT_MS,
@@ -212,9 +210,8 @@ export interface SearchOptions {
   /**
    * Optional embedding provider for query-side encoding (i-loazq6ze).
    * When supplied, vec/hyde sub-queries are encoded through the provider
-   * (HTTP / GPU worker / AutoFallback chain) instead of the local llama-cpp
-   * model. Omit to keep pre-patch behavior — the SDK store still works
-   * unchanged for callers that have not opted into a remote provider.
+   * through the configured commercial API. Without one, learned work returns
+   * typed HOLD.
    */
   embedProvider?: EmbeddingProvider;
 }
@@ -235,7 +232,7 @@ export interface VectorSearchOptions {
   collection?: string;
   /**
    * Optional embedding provider for query encoding (i-loazq6ze). Forwarded
-   * through to `searchVec`. Defaults to local llama-cpp.
+   * through to `searchVec`. Without one, learned work returns typed HOLD.
    */
   embedProvider?: EmbeddingProvider;
 }
@@ -262,11 +259,10 @@ export interface StoreOptions {
   /** Inline collection config (mutually exclusive with `configPath`) */
   config?: CollectionConfig;
   /**
-   * Optional default embedding provider for query encoding (i-loazq6ze).
-   * When set, every `store.search(...)` call uses this provider unless the
-   * caller passes its own `embedProvider` in `SearchOptions`. MCP / HTTP
-   * server constructs the provider once at startup and injects it here so
-   * every query routes through the GPU worker.
+   * Optional default commercial embedding provider. When set, every learned
+   * embedding operation uses this provider unless a search call supplies its
+   * own `embedProvider`. MCP / HTTP constructs the provider once at startup
+   * and injects it here so every query routes through the approved API.
    */
   embedProvider?: EmbeddingProvider;
 }
@@ -275,8 +271,8 @@ export interface StoreOptions {
  * The QMD SDK store — provides search, retrieval, collection management,
  * context management, and indexing operations.
  *
- * All methods are async. The store manages its own LlamaCpp instance
- * (lazy-loaded, auto-unloaded after inactivity) — no global singletons.
+ * All methods are async. Learned work uses an injected commercial provider or
+ * the fail-closed compatibility adapter.
  */
 export interface QMDStore {
   /** The underlying internal store (for advanced use) */
@@ -371,7 +367,7 @@ export interface QMDStore {
 
   // ── Lifecycle ───────────────────────────────────────────────────────
 
-  /** Close the store and release all resources (LLM models, DB connection) */
+  /** Close the store and release provider and database resources. */
   close(): Promise<void>;
 }
 
@@ -430,8 +426,8 @@ export async function createStore(options: StoreOptions): Promise<QMDStore> {
   }
   // else: DB-only mode — no external config, use existing store_collections
 
-  // Create a per-store LlamaCpp instance — lazy-loads models on first use,
-  // auto-unloads after 5 min inactivity to free VRAM.
+  // Compatibility adapter for learned operations that lack an injected
+  // commercial provider. Every such call returns typed HOLD.
   const llm = new LlamaCpp({
     embedModel: config?.models?.embed,
     generateModel: config?.models?.generate,
@@ -592,6 +588,7 @@ export async function createStore(options: StoreOptions): Promise<QMDStore> {
         maxBatchBytes: embedOpts?.maxBatchBytes,
         chunkStrategy: embedOpts?.chunkStrategy,
         onProgress: embedOpts?.onProgress,
+        embedProvider: options.embedProvider,
       });
     },
 

+ 86 - 30
src/store.ts

@@ -1353,13 +1353,23 @@ export type EmbedProgress = {
   totalChunks: number;
   bytesProcessed: number;
   totalBytes: number;
+  /** Chunks that were sent to the provider and came back without an embedding. */
   errors: number;
+  /**
+   * Chunks the run gave up on WITHOUT sending them (abort / expired session).
+   * Kept separate from `errors` so "we stopped early" can never be reported as
+   * "N chunks failed" — see `generateEmbeddings` (i-yghj098h).
+   */
+  skipped?: number;
 };
 
 export type EmbedResult = {
   docsProcessed: number;
   chunksEmbedded: number;
+  /** Attempted-and-failed chunks only. Never includes un-attempted ones. */
   errors: number;
+  /** Un-attempted chunks left behind by an early abort. */
+  skipped?: number;
   durationMs: number;
 };
 
@@ -1626,9 +1636,16 @@ export async function generateEmbeddings(
   const result = await withEmbedSession(store, provider, async (session) => {
     let chunksEmbedded = 0;
     let errors = 0;
+    let skipped = 0;
     let bytesProcessed = 0;
     let totalChunks = 0;
     let vectorTableInitialized = false;
+    // Set once the run decides to stop early. The high-error-rate guard used to
+    // `break` the INNER batch loop only, so the very next document re-evaluated
+    // the same cumulative ratio and broke again — one transient 429 could book
+    // ~740k never-attempted chunks as "failures" across ~50 aborts
+    // (i-yghj098h). The flag stops the OUTER loop too, exactly once.
+    let abortRun = false;
     // Inner batch size — number of chunks fed into each `embedMany` call.
     // Bumped 32 → 256 (i-fkpnar9i) so the openai provider's concurrent
     // dispatcher receives ≥ 4 sub-chunks of size 64 (worker MAX_BATCH) and
@@ -1637,6 +1654,33 @@ export async function generateEmbeddings(
     const BATCH_SIZE = parseInt(process.env.QMD_EMBED_INNER_BATCH_SIZE ?? "256", 10) || 256;
     const batches = buildEmbeddingBatches(docsToEmbed, maxDocsPerBatch, maxBatchBytes);
 
+    // Sliding error-rate window (i-yghj098h). The abort guard used to compare
+    // CUMULATIVE `errors` against CUMULATIVE processed, and fed its own
+    // un-attempted remainders back into `errors` — so once tripped the ratio
+    // could never fall back under the threshold and the run was poisoned for
+    // good. Judging only the last few ATTEMPTED batches means a burst of 429s
+    // that the provider then rides out (retry + lane cooldown) no longer
+    // condemns the rest of the run.
+    const ERROR_WINDOW_BATCHES = parseInt(
+      process.env.QMD_EMBED_ERROR_WINDOW_BATCHES ?? "8", 10,
+    ) || 8;
+    const ERROR_RATE_ABORT_THRESHOLD = 0.8;
+    const recentBatches: { attempted: number; errors: number }[] = [];
+    const noteBatchOutcome = (attempted: number, failed: number): void => {
+      if (attempted <= 0) return;
+      recentBatches.push({ attempted, errors: failed });
+      while (recentBatches.length > ERROR_WINDOW_BATCHES) recentBatches.shift();
+    };
+    const windowTotals = (): { attempted: number; errors: number } => {
+      let attempted = 0;
+      let failed = 0;
+      for (const b of recentBatches) {
+        attempted += b.attempted;
+        failed += b.errors;
+      }
+      return { attempted, errors: failed };
+    };
+
     // Embedding helpers — single point of provider/session selection.
     // Both return the same shape as ILLMSession.embed/embedBatch so the
     // rest of the loop is unchanged.
@@ -1645,8 +1689,7 @@ export async function generateEmbeddings(
       modelArg: string,
     ): Promise<{ embedding: number[]; model: string } | null> => {
       if (provider) {
-        const sig = provider.kind === 'local' ? session.signal : undefined;
-        const r = await provider.embed(text, { model: modelArg, signal: sig });
+        const r = await provider.embed(text, { model: modelArg });
         return r ? { embedding: r.embedding, model: r.model } : null;
       }
       return session.embed(text, { model: modelArg });
@@ -1656,8 +1699,7 @@ export async function generateEmbeddings(
       modelArg: string,
     ): Promise<({ embedding: number[]; model: string } | null)[]> => {
       if (provider) {
-        const sig = provider.kind === 'local' ? session.signal : undefined;
-        const r = await provider.embedBatch(texts, { model: modelArg, signal: sig });
+        const r = await provider.embedBatch(texts, { model: modelArg });
         return r.map((x) => (x ? { embedding: x.embedding, model: x.model } : null));
       }
       return session.embedBatch(texts, { model: modelArg });
@@ -1715,7 +1757,7 @@ export async function generateEmbeddings(
 
       if (batchChunks.length === 0) {
         bytesProcessed += batchBytes;
-        options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors });
+        options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors, skipped });
         continue;
       }
 
@@ -1764,17 +1806,28 @@ export async function generateEmbeddings(
         // Abort early if session has been invalidated (e.g. max duration exceeded)
         if (!session.isValid) {
           const remaining = batchChunks.length - batchStart;
-          errors += remaining;
+          skipped += remaining;
+          abortRun = true;
           console.warn(`⚠ Session expired — skipping ${remaining} remaining chunks`);
           break;
         }
 
-        // Abort early if error rate is too high (>80% of processed chunks failed)
-        const processed = chunksEmbedded + errors;
-        if (processed >= BATCH_SIZE && errors > processed * 0.8) {
+        // Abort early if the RECENT attempted batches are overwhelmingly failing
+        // (>80% over the sliding window). Un-attempted chunks are booked as
+        // `skipped`, never as `errors`.
+        const window = windowTotals();
+        if (
+          window.attempted >= BATCH_SIZE &&
+          window.errors > window.attempted * ERROR_RATE_ABORT_THRESHOLD
+        ) {
           const remaining = batchChunks.length - batchStart;
-          errors += remaining;
-          console.warn(`⚠ Error rate too high (${errors}/${processed}) — aborting embedding`);
+          skipped += remaining;
+          abortRun = true;
+          console.warn(
+            `⚠ Error rate too high (${window.errors}/${window.attempted} over last ` +
+            `${recentBatches.length} batches) — aborting embedding ` +
+            `(${remaining} chunks in this document left unattempted)`,
+          );
           break;
         }
 
@@ -1814,14 +1867,17 @@ export async function generateEmbeddings(
           const { okCount, errCount } = insertBatchTxn();
           chunksEmbedded += okCount;
           errors += errCount;
+          noteBatchOutcome(chunkBatch.length, errCount);
           batchChunkBytesProcessed += chunkBatch.reduce((sum, c) => sum + c.bytes, 0);
         } catch {
           // Batch failed — try individual embeddings as fallback
           // But skip if session is already invalid (avoids N doomed retries)
           if (!session.isValid) {
             errors += chunkBatch.length;
+            noteBatchOutcome(chunkBatch.length, chunkBatch.length);
             batchChunkBytesProcessed += chunkBatch.reduce((sum, c) => sum + c.bytes, 0);
           } else {
+            let fallbackErrors = 0;
             for (const chunk of chunkBatch) {
               try {
                 const text = formatDocForEmbedding(chunk.text, chunk.title, embedModelUri);
@@ -1831,12 +1887,15 @@ export async function generateEmbeddings(
                   chunksEmbedded++;
                 } else {
                   errors++;
+                  fallbackErrors++;
                 }
               } catch {
                 errors++;
+                fallbackErrors++;
               }
               batchChunkBytesProcessed += chunk.bytes;
             }
+            noteBatchOutcome(chunkBatch.length, fallbackErrors);
           }
         }
 
@@ -1849,20 +1908,25 @@ export async function generateEmbeddings(
           bytesProcessed: bytesProcessed + proportionalBytes,
           totalBytes,
           errors,
+          skipped,
         });
       }
 
       bytesProcessed += batchBytes;
-      options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors });
+      options?.onProgress?.({ chunksEmbedded, totalChunks, bytesProcessed, totalBytes, errors, skipped });
+
+      // One abort ends the run — it must not re-trip on every remaining doc.
+      if (abortRun) break;
     }
 
-    return { chunksEmbedded, errors };
+    return { chunksEmbedded, errors, skipped };
   }, { maxDuration: 30 * 60 * 1000, name: 'generateEmbeddings' });
 
   return {
     docsProcessed: totalDocs,
     chunksEmbedded: result.chunksEmbedded,
     errors: result.errors,
+    skipped: result.skipped,
     durationMs: Date.now() - startTime,
   };
 }
@@ -3549,11 +3613,7 @@ async function getEmbedding(text: string, model: string, isQuery: boolean, sessi
     const formattedText = isQuery
       ? formatQueryForEmbedding(text, providerModel)
       : formatDocForEmbedding(text, undefined, providerModel);
-    // Only forward an AbortSignal when the provider is local-backed;
-    // remote providers manage their own timeouts and an LLM-session signal
-    // would abort their HTTP request prematurely (i-08ovbvtb).
-    const sig = embedProvider.kind === "local" ? session?.signal : undefined;
-    const result = await embedProvider.embed(formattedText, sig ? { model: providerModel, signal: sig } : { model: providerModel });
+    const result = await embedProvider.embed(formattedText, { model: providerModel });
     return result?.embedding ?? null;
   }
   // Format text using the appropriate prompt template
@@ -4355,9 +4415,8 @@ export interface HybridQueryOptions {
   /**
    * Optional embedding provider for query-side encoding (i-loazq6ze).
    * When supplied, the original-query vector AND any vec/hyde expansion
-   * variants are encoded through this provider (HTTP, GPU worker,
-   * AutoFallback chain) instead of `getLlm(store).embedBatch(...)`. Skip
-   * to keep pre-patch behavior (uses local LlamaCpp).
+   * variants are encoded through this commercial provider. Without one,
+   * learned work returns typed HOLD.
    */
   embedProvider?: EmbeddingProvider;
 }
@@ -4482,9 +4541,8 @@ export async function hybridQuery(
     }
 
     // Batch embed all vector queries in a single call.
-    // When `embedProvider` is supplied (i-loazq6ze), route the encode through
-    // it (HTTP / GPU worker / AutoFallback chain) instead of warming the
-    // local llama-cpp model — this is the whole point of the GPU worker.
+    // Route query embeddings through the injected commercial provider. The
+    // compatibility path below is fail-closed and returns typed HOLD.
     const embedModelName = embedProvider
       ? embedProvider.getModelId()
       : getLlm(store).embedModelName;
@@ -4691,8 +4749,8 @@ export interface VectorSearchOptions {
   hooks?: Pick<SearchHooks, 'onExpand'>;
   /**
    * Optional embedding provider for query-side encoding (i-loazq6ze).
-   * When supplied, query vectors are encoded via the provider (HTTP /
-   * GPU worker / fallback chain) instead of the local llama-cpp model.
+   * When supplied, query vectors are encoded through the commercial API.
+   * Without one, learned work returns typed HOLD.
    */
   embedProvider?: EmbeddingProvider;
 }
@@ -4891,10 +4949,8 @@ export async function structuredSearch(
         s.type === 'vec' || s.type === 'hyde'
     );
     if (vecSearches.length > 0) {
-      // Route batch encoding through the supplied EmbeddingProvider when
-      // present (i-loazq6ze). Otherwise fall back to the local llama-cpp
-      // singleton — preserves pre-patch behavior for callers that don't
-      // configure a provider.
+      // Route batch encoding through the supplied commercial provider. The
+      // compatibility branch returns typed HOLD when no provider is configured.
       const embedModelName = embedProvider
         ? embedProvider.getModelId()
         : getLlm(store).embedModelName;

+ 28 - 0
test/cli.test.ts

@@ -295,6 +295,34 @@ describe("CLI Embed", () => {
   });
 });
 
+describe("CLI commercial-only model policy", () => {
+  test("qmd pull returns typed HOLD and never downloads model artifacts", async () => {
+    const { stderr, exitCode } = await runQmd(["pull"]);
+
+    expect(exitCode).toBe(1);
+    expect(stderr).toContain("QMD_COMMERCIAL_API_HOLD");
+  });
+
+  test("local provider selection returns typed HOLD", async () => {
+    const { stderr, exitCode } = await runQmd(["embed", "--provider", "local"]);
+
+    expect(exitCode).toBe(1);
+    expect(stderr).toContain("QMD_COMMERCIAL_API_HOLD");
+  });
+
+  test("automatic model fallback returns typed HOLD", async () => {
+    const { stderr, exitCode } = await runQmd([
+      "embed",
+      "--embed-auto-fallback",
+      "--embed-endpoint",
+      "https://commercial.example.com/v1",
+    ]);
+
+    expect(exitCode).toBe(1);
+    expect(stderr).toContain("QMD_COMMERCIAL_API_HOLD");
+  });
+});
+
 describe("CLI Skill Commands", () => {
   test("shows embedded skill with --skill alias", async () => {
     const { stdout, exitCode } = await runQmd(["--skill"]);

+ 82 - 0
test/commercial-model-policy.test.ts

@@ -0,0 +1,82 @@
+import { readFileSync } from "node:fs";
+
+import { describe, expect, test } from "vitest";
+
+import * as embeddingApi from "../src/embedding/index.js";
+import * as sdk from "../src/index.js";
+import { LocalLlamaCppProvider } from "../src/embedding/local.js";
+import { LlamaCpp, pullModels } from "../src/llm.js";
+import {
+  COMMERCIAL_API_HOLD_CODE,
+  CommercialApiHoldError,
+} from "../src/model-policy.js";
+
+function expectTypedHold(error: unknown): void {
+  expect(error).toBeInstanceOf(CommercialApiHoldError);
+  expect(error).toMatchObject({
+    code: COMMERCIAL_API_HOLD_CODE,
+    disposition: "HOLD",
+  });
+}
+
+describe("commercial-only package policy", () => {
+  test("the public SDK does not export local or fallback providers", () => {
+    for (const api of [sdk, embeddingApi]) {
+      expect(api).not.toHaveProperty("LocalLlamaCppProvider");
+      expect(api).not.toHaveProperty("AutoFallbackEmbeddingProvider");
+    }
+  });
+
+  test("package manifests contain no local model runtime or model artifact dependency", () => {
+    const manifest = JSON.parse(
+      readFileSync(new URL("../package.json", import.meta.url), "utf8"),
+    ) as Record<string, Record<string, string> | undefined>;
+    const dependencyNames = [
+      ...Object.keys(manifest.dependencies ?? {}),
+      ...Object.keys(manifest.optionalDependencies ?? {}),
+      ...Object.keys(manifest.devDependencies ?? {}),
+    ];
+
+    expect(dependencyNames).not.toContain("node-llama-cpp");
+    for (const lockFile of ["../pnpm-lock.yaml", "../bun.lock"]) {
+      const lock = readFileSync(new URL(lockFile, import.meta.url), "utf8");
+      expect(lock).not.toMatch(/node-llama-cpp|\.gguf\b|huggingface\.co/i);
+    }
+  });
+
+  test("historical local construction and model pulls return typed HOLD", async () => {
+    try {
+      new LocalLlamaCppProvider();
+      throw new Error("local provider construction unexpectedly succeeded");
+    } catch (error) {
+      expectTypedHold(error);
+    }
+
+    try {
+      await pullModels(["forbidden-model-artifact"]);
+      throw new Error("model pull unexpectedly succeeded");
+    } catch (error) {
+      expectTypedHold(error);
+    }
+  });
+
+  test("learned compatibility operations fail closed without a commercial adapter", async () => {
+    const llm = new LlamaCpp();
+    const operations = [
+      () => llm.embed("text"),
+      () => llm.embedBatch(["text"]),
+      () => llm.generate("prompt"),
+      () => llm.expandQuery("query"),
+      () => llm.rerank("query", [{ file: "doc", text: "body" }]),
+    ];
+
+    for (const operation of operations) {
+      try {
+        await operation();
+        throw new Error("learned operation unexpectedly succeeded");
+      } catch (error) {
+        expectTypedHold(error);
+      }
+    }
+  });
+});

+ 20 - 12
test/embedding-autofallback.test.ts

@@ -1,5 +1,6 @@
 /**
- * embedding-autofallback.test.ts - Tests for AutoFallbackEmbeddingProvider.
+ * Historical fallback behavior is retained below as skipped documentation.
+ * The active contract requires construction to fail closed.
  */
 
 import { describe, test, expect } from "vitest";
@@ -8,6 +9,7 @@ import {
   type AutoFallbackProviderConfig,
 } from "../src/embedding/autofallback.js";
 import { CircuitOpenError } from "../src/embedding/openai.js";
+import { CommercialApiHoldError } from "../src/model-policy.js";
 import type {
   EmbeddingProvider,
   ProviderEmbedOptions,
@@ -92,7 +94,7 @@ function buildAutoFallback(opts: Partial<AutoFallbackProviderConfig> = {}): {
   setNow: (n: number) => void;
 } {
   const primary = new FakeProvider("openai", "embeddinggemma");
-  const fallback = new FakeProvider("local", "embeddinggemma");
+  const fallback = new FakeProvider("openai", "embeddinggemma");
   const warns: string[] = [];
   let now = 1_000_000;
   const af = new AutoFallbackEmbeddingProvider({
@@ -109,14 +111,20 @@ function buildAutoFallback(opts: Partial<AutoFallbackProviderConfig> = {}): {
 
 // ─────────────────────────── Construction ────────────────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — construction", () => {
+describe("AutoFallbackEmbeddingProvider — commercial-only policy", () => {
+  test("construction returns typed HOLD", () => {
+    expect(() => buildAutoFallback()).toThrow(CommercialApiHoldError);
+  });
+});
+
+describe.skip("AutoFallbackEmbeddingProvider — historical construction", () => {
   test("requires primary", () => {
     expect(
       () =>
         new AutoFallbackEmbeddingProvider({
           // @ts-expect-error testing runtime guard
           primary: undefined,
-          fallback: new FakeProvider("local", "x"),
+          fallback: new FakeProvider("openai", "x"),
         }),
     ).toThrow(/primary is required/);
   });
@@ -151,7 +159,7 @@ describe("AutoFallbackEmbeddingProvider — construction", () => {
 
 // ─────────────────────────── Happy path ──────────────────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — happy path", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical happy path", () => {
   test("primary succeeds → fallback never called", async () => {
     const { af, primary, fallback } = buildAutoFallback();
     const r = await af.embed("hello");
@@ -178,7 +186,7 @@ describe("AutoFallbackEmbeddingProvider — happy path", () => {
 
 // ─────────────────────────── Circuit-open fallback ───────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — CircuitOpenError handling", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical CircuitOpenError handling", () => {
   test("primary throws CircuitOpenError → fallback served + cooldown opens", async () => {
     const { af, primary, fallback, warns } = buildAutoFallback();
     primary.nextThrows.push(new CircuitOpenError());
@@ -231,7 +239,7 @@ describe("AutoFallbackEmbeddingProvider — CircuitOpenError handling", () => {
 
 // ─────────────────────────── Failure-streak threshold ────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — failure streak", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical failure streak", () => {
   test("non-CircuitOpen errors below threshold → no cooldown", async () => {
     const { af, primary, fallback } = buildAutoFallback({ failureStreakThreshold: 3 });
     primary.nextThrows.push(new Error("transient"));
@@ -274,7 +282,7 @@ describe("AutoFallbackEmbeddingProvider — failure streak", () => {
 
 // ─────────────────────────── Recovery transition ─────────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — recovery transitions", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical recovery transitions", () => {
   test("recovery WARN fires when primary call succeeds after fallback", async () => {
     const { af, primary, warns, setNow } = buildAutoFallback({ cooldownMs: 5000 });
     primary.nextThrows.push(new CircuitOpenError());
@@ -299,7 +307,7 @@ describe("AutoFallbackEmbeddingProvider — recovery transitions", () => {
 
 // ─────────────────────────── Both fail ───────────────────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — both providers fail", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical both providers fail", () => {
   test("primary throws + fallback throws → embedBatch returns nulls", async () => {
     const { af, primary, fallback } = buildAutoFallback();
     primary.alwaysThrows = new Error("primary down");
@@ -318,7 +326,7 @@ describe("AutoFallbackEmbeddingProvider — both providers fail", () => {
 
 // ─────────────────────────── Healthcheck ─────────────────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — healthcheck", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical healthcheck", () => {
   test("primary healthy → returns primary health", async () => {
     const { af, primary, fallback } = buildAutoFallback();
     const h = await af.healthcheck();
@@ -350,7 +358,7 @@ describe("AutoFallbackEmbeddingProvider — healthcheck", () => {
 
 // ─────────────────────────── getLastError (i-vm1lxwry) ──────────────────────
 
-describe("AutoFallbackEmbeddingProvider — getLastError (i-vm1lxwry)", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical getLastError (i-vm1lxwry)", () => {
   test("returns undefined when both legs are clean", () => {
     const { af, primary, fallback } = buildAutoFallback();
     primary.lastErr = undefined;
@@ -386,7 +394,7 @@ describe("AutoFallbackEmbeddingProvider — getLastError (i-vm1lxwry)", () => {
 
 // ─────────────────────────── dispose ─────────────────────────────────────────
 
-describe("AutoFallbackEmbeddingProvider — dispose", () => {
+describe.skip("AutoFallbackEmbeddingProvider — historical dispose", () => {
   test("dispose cascades to both providers", async () => {
     const { af, primary, fallback } = buildAutoFallback();
     await af.dispose();

+ 9 - 7
test/embedding-factory.test.ts

@@ -48,7 +48,7 @@ const EMPTY_ENV: Record<string, string | undefined> = {};
 describe("resolveProviderKind", () => {
   test("explicit local kind is rejected", () => {
     expect(() => resolveProviderKind({
-        kind: "local",
+        kind: "local" as never,
         env: { QMD_EMBED_ENDPOINT: "https://x" },
         configPath,
       })).toThrow(CommercialApiHoldError);
@@ -100,13 +100,13 @@ describe("resolveProviderKind", () => {
     expect(resolveProviderKind({ env: EMPTY_ENV, configPath })).toBe("openai");
   });
 
-  test("invalid env QMD_EMBED_PROVIDER is ignored", () => {
-    expect(
+  test("unsupported env provider returns typed HOLD", () => {
+    expect(() =>
       resolveProviderKind({
         env: { QMD_EMBED_PROVIDER: "garbage" },
         configPath,
       }),
-    ).toBe("openai");
+    ).toThrow(CommercialApiHoldError);
   });
 
   test("uppercase env QMD_EMBED_PROVIDER normalized", () => {
@@ -225,7 +225,7 @@ describe("createEmbeddingProvider", () => {
 
   test("local kind explicitly requested → typed HOLD", () => {
     expect(() => createEmbeddingProvider({
-      kind: "local",
+      kind: "local" as never,
       env: EMPTY_ENV,
       configPath,
     })).toThrow(CommercialApiHoldError);
@@ -238,8 +238,10 @@ describe("createEmbeddingProvider", () => {
 
   test("legacy auto-fallback request → typed HOLD", () => {
     expect(() => createEmbeddingProvider({
-      env: { QMD_EMBED_ENDPOINT: "https://commercial.example.com" },
-      autoFallback: true,
+      env: {
+        QMD_EMBED_ENDPOINT: "https://commercial.example.com",
+        QMD_EMBED_AUTO_FALLBACK: "1",
+      },
       configPath,
     })).toThrow(CommercialApiHoldError);
   });

+ 215 - 0
test/embedding-openai.test.ts

@@ -1098,3 +1098,218 @@ describe("OpenAIEmbeddingsProvider — dispose", () => {
     expect(p.breaker.getState()).toBe("closed");
   });
 });
+
+// ────────── rate-limit budget + bulk lane (i-yghj098h) ───────────────────────
+
+import {
+  parseRetryAfterMs,
+  BulkLaneGate,
+  RATE_LIMIT_MAX_BACKOFF_MS,
+  DEFAULT_RATE_LIMIT_RETRIES,
+  LANE_RECOVERY_STREAK,
+} from "../src/embedding/openai.js";
+
+/** The shape ai.mm.mk actually returns when the token bucket is empty. */
+function rateLimitedResponse(seconds: number, opts?: { header?: boolean }): Response {
+  const headers: Record<string, string> = { "content-type": "application/json" };
+  if (opts?.header) headers["retry-after"] = String(seconds);
+  return new Response(
+    JSON.stringify({
+      detail: `Rate limit exceeded (tokens). Retry after ${seconds}s.`,
+      error: "rate_limited",
+    }),
+    { status: 429, headers },
+  );
+}
+
+describe("parseRetryAfterMs", () => {
+  test("delta-seconds header", () => {
+    expect(parseRetryAfterMs("29")).toBe(29_000);
+  });
+  test("HTTP-date header", () => {
+    const now = () => 1_000_000;
+    const when = new Date(now() + 12_000).toUTCString();
+    // toUTCString truncates to whole seconds — allow the rounding slack.
+    expect(parseRetryAfterMs(when, undefined, now)).toBeGreaterThan(11_000);
+  });
+  test("falls back to the ai.mm.mk body prose", () => {
+    expect(
+      parseRetryAfterMs(null, '{"detail":"Rate limit exceeded (tokens). Retry after 29s."}'),
+    ).toBe(29_000);
+  });
+  test("clamps absurd values", () => {
+    expect(parseRetryAfterMs("99999")).toBe(RATE_LIMIT_MAX_BACKOFF_MS);
+  });
+  test("undefined when nothing parseable", () => {
+    expect(parseRetryAfterMs(null, "no numbers here")).toBeUndefined();
+    expect(parseRetryAfterMs("0")).toBeUndefined();
+  });
+});
+
+describe("OpenAIEmbeddingsProvider — 429 honours Retry-After (i-yghj098h)", () => {
+  test("waits the server-advertised cooldown from the body, not the 1s schedule", async () => {
+    const sleepCalls: number[] = [];
+    const { fetchImpl } = makeFetchSequence([
+      () => rateLimitedResponse(29),
+      () => embeddingsResponse(["x"], 4),
+    ]);
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://ai.mm.mk",
+      fetchImpl,
+      retryBackoffsMs: [1_000, 4_000, 16_000],
+      sleep: async (ms) => { sleepCalls.push(ms); },
+    });
+    const r = await p.embed("x");
+    expect(r).not.toBeNull();
+    expect(sleepCalls).toEqual([29_000]);
+  });
+
+  test("Retry-After header wins over the body", async () => {
+    const sleepCalls: number[] = [];
+    const { fetchImpl } = makeFetchSequence([
+      () => rateLimitedResponse(7, { header: true }),
+      () => embeddingsResponse(["x"], 4),
+    ]);
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://ai.mm.mk",
+      fetchImpl,
+      retryBackoffsMs: [],
+      sleep: async (ms) => { sleepCalls.push(ms); },
+    });
+    expect(await p.embed("x")).not.toBeNull();
+    expect(sleepCalls).toEqual([7_000]);
+  });
+
+  test("429 gets its own budget — survives more rate limits than the generic schedule", async () => {
+    const sleepCalls: number[] = [];
+    const { fetchImpl, calls } = makeFetchSequence([
+      () => rateLimitedResponse(1),
+      () => rateLimitedResponse(1),
+      () => rateLimitedResponse(1),
+      () => rateLimitedResponse(1),
+      () => embeddingsResponse(["x"], 4),
+    ]);
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://ai.mm.mk",
+      fetchImpl,
+      // A single generic retry — before the fix this run gave up on call 2.
+      retryBackoffsMs: [10],
+      sleep: async (ms) => { sleepCalls.push(ms); },
+    });
+    expect(await p.embed("x")).not.toBeNull();
+    expect(calls.length).toBe(5);
+    expect(sleepCalls.length).toBe(4);
+    expect(DEFAULT_RATE_LIMIT_RETRIES).toBeGreaterThanOrEqual(4);
+  });
+
+  test("429 exhaustion still fails, and does not trip the circuit breaker", async () => {
+    const { fetchImpl } = makeFetchSequence(
+      Array.from({ length: 12 }, () => () => rateLimitedResponse(1)),
+    );
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://ai.mm.mk",
+      fetchImpl,
+      retryBackoffsMs: [],
+      rateLimitRetries: 1,
+      sleep: async () => {},
+    });
+    for (let i = 0; i < 6; i++) expect(await p.embed("x")).toBeNull();
+    // Backpressure must not become a 5-minute hard OPEN.
+    expect(p.breaker.getState()).toBe("closed");
+  });
+});
+
+describe("BulkLaneGate", () => {
+  test("halves permits per cooldown and coalesces concurrent penalties", async () => {
+    const slept: number[] = [];
+    const gate = new BulkLaneGate(4, async (ms) => { slept.push(ms); });
+    await Promise.all([gate.penalize(5_000), gate.penalize(5_000)]);
+    expect(slept).toEqual([5_000]);
+    expect(gate.permitCount).toBe(2);
+    await gate.penalize(5_000);
+    expect(gate.permitCount).toBe(1);
+    await gate.penalize(5_000);
+    expect(gate.permitCount).toBe(1); // floor
+  });
+
+  test("recovers one permit per success streak", async () => {
+    const gate = new BulkLaneGate(4, async () => {});
+    await gate.penalize(1_000);
+    expect(gate.permitCount).toBe(2);
+    for (let i = 0; i < LANE_RECOVERY_STREAK; i++) gate.noteSuccess();
+    expect(gate.permitCount).toBe(3);
+  });
+
+  test("acquire/release respects the current cap", async () => {
+    const gate = new BulkLaneGate(1, async () => {});
+    await gate.acquire();
+    let secondEntered = false;
+    const second = gate.acquire().then(() => { secondEntered = true; });
+    await Promise.resolve();
+    expect(secondEntered).toBe(false);
+    gate.release();
+    await second;
+    expect(secondEntered).toBe(true);
+  });
+});
+
+describe("bulk lane — X-AI-Caller attribution + shared cooldown", () => {
+  function callerHeader(init?: RequestInit): string {
+    const headers = (init?.headers ?? {}) as Record<string, string>;
+    return headers["X-AI-Caller"] ?? "";
+  }
+
+  test("multi-input batches are attributed as embeddings-bulk", async () => {
+    const { fetchImpl, calls } = makeFetchSequence([
+      () => embeddingsResponse(["a", "b"], 4),
+    ]);
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://ai.mm.mk",
+      fetchImpl,
+      sleep: async () => {},
+    });
+    await p.embedBatch(["a", "b"]);
+    expect(callerHeader(calls[0]!.init)).toContain("via=embeddings-bulk");
+    expect(callerHeader(calls[0]!.init)).toContain("comp=qmd");
+  });
+
+  test("single query-time embeds stay on the interactive label", async () => {
+    const { fetchImpl, calls } = makeFetchSequence([
+      () => embeddingsResponse(["a"], 4),
+    ]);
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://ai.mm.mk",
+      fetchImpl,
+      sleep: async () => {},
+    });
+    await p.embed("a");
+    expect(callerHeader(calls[0]!.init)).toContain("via=embeddings");
+    expect(callerHeader(calls[0]!.init)).not.toContain("via=embeddings-bulk");
+  });
+
+  test("one 429 pauses the whole bulk pool once and narrows concurrency", async () => {
+    const sleepCalls: number[] = [];
+    let call = 0;
+    const fetchImpl = (async (_input: unknown, _init?: RequestInit) => {
+      call++;
+      if (call === 1) return rateLimitedResponse(30);
+      return embeddingsResponse(["x"], 4);
+    }) as unknown as typeof fetch;
+
+    const p = new OpenAIEmbeddingsProvider({
+      endpoint: "https://ai.mm.mk",
+      fetchImpl,
+      batchSize: 1,
+      concurrency: 2,
+      retryBackoffsMs: [],
+      sleep: async (ms) => { sleepCalls.push(ms); },
+    });
+
+    const out = await p.embedBatch(["a", "b", "c", "d"]);
+    expect(out.every((r) => r !== null)).toBe(true);
+    // Exactly one cooldown, at the length the gateway asked for.
+    expect(sleepCalls).toEqual([30_000]);
+    // AIMD: the lane gave capacity back to interactive traffic.
+    expect(p.lane.permitCount).toBe(1);
+  });
+});

+ 77 - 0
test/embedding-store-integration.test.ts

@@ -387,3 +387,80 @@ describe("first-chunk dimension probe — retry + rich error (i-vm1lxwry)", () =
     expect(provider2.callIdx).toBeGreaterThanOrEqual(2);
   });
 });
+
+// ────────── high-error-rate abort accounting (i-yghj098h) ────────────────────
+
+/**
+ * Insert `count` extra documents so a run has several chunks to walk through.
+ * Bodies differ so the chunker/embedder can't collapse them.
+ */
+function insertExtraDocs(s: Store, count: number): void {
+  const now = "2026-04-27T00:00:00Z";
+  for (let i = 0; i < count; i++) {
+    const hash = `extra${i}`;
+    s.db
+      .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
+      .run(hash, `Extra document number ${i} with its own body text to chunk.`, now);
+    s.db
+      .prepare(
+        `INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`,
+      )
+      .run(hash, "test", `extra-${i}.md`, `Extra ${i}`, now, now, 1);
+  }
+}
+
+describe("generateEmbeddings — abort accounting (i-yghj098h)", () => {
+  const prevInnerBatch = process.env.QMD_EMBED_INNER_BATCH_SIZE;
+
+  afterEach(() => {
+    if (prevInnerBatch === undefined) delete process.env.QMD_EMBED_INNER_BATCH_SIZE;
+    else process.env.QMD_EMBED_INNER_BATCH_SIZE = prevInnerBatch;
+  });
+
+  test("an early abort books un-attempted chunks as skipped, never as errors", async () => {
+    // One chunk per HTTP batch so a single failure is enough to trip the
+    // >80% guard on the very next iteration.
+    process.env.QMD_EMBED_INNER_BATCH_SIZE = "1";
+    insertExtraDocs(store, 4);
+
+    // probe ok → first real batch fails → everything after would succeed,
+    // so anything counted as an error past the abort is phantom.
+    const provider = new FlakyProvider("embeddinggemma", 4, [true, false, true]);
+
+    let totalChunks = 0;
+    const result = await generateEmbeddings(store, {
+      embedProvider: provider,
+      onProgress: (p) => { totalChunks = Math.max(totalChunks, p.totalChunks); },
+    });
+
+    // Exactly one chunk was actually sent and came back empty.
+    expect(result.errors).toBe(1);
+    // The rest were never attempted — they are skipped, not failures. Before
+    // the fix `errors` absorbed every un-attempted remainder on every
+    // subsequent document, so it could exceed the corpus several times over.
+    expect(result.skipped ?? 0).toBeGreaterThan(0);
+    expect(result.chunksEmbedded).toBe(0);
+    // The books must balance: nothing can be counted twice.
+    expect(result.chunksEmbedded + result.errors + (result.skipped ?? 0)).toBe(totalChunks);
+    expect(result.errors).toBeLessThanOrEqual(totalChunks);
+  });
+
+  test("an isolated failed batch does not abort the rest of the run", async () => {
+    process.env.QMD_EMBED_INNER_BATCH_SIZE = "2";
+    insertExtraDocs(store, 8);
+
+    // probe ok, batch1 ok, batch2 empty, everything after ok → the window
+    // error rate falls back under the threshold and the run continues.
+    const provider = new FlakyProvider("embeddinggemma", 4, [true, true, false, true]);
+
+    let totalChunks = 0;
+    const result = await generateEmbeddings(store, {
+      embedProvider: provider,
+      onProgress: (p) => { totalChunks = Math.max(totalChunks, p.totalChunks); },
+    });
+
+    expect(result.chunksEmbedded).toBeGreaterThan(0);
+    expect(result.skipped ?? 0).toBe(0);
+    expect(result.chunksEmbedded + result.errors).toBe(totalChunks);
+  });
+});

+ 40 - 89
test/embedding-vsearch.test.ts

@@ -3,10 +3,8 @@
  * (issue i-loazq6ze).
  *
  * Verifies that `searchVec`, `structuredSearch`, and `vectorSearchQuery`
- * route query encoding through the supplied `EmbeddingProvider` instead
- * of the local `node-llama-cpp` model when one is configured. Also covers
- * the AutoFallback path so a transient remote outage degrades to local
- * instead of throwing.
+ * route query encoding through the supplied commercial `EmbeddingProvider`.
+ * Provider failures propagate without selecting a model fallback.
  *
  * The store is in-memory (sqlite + sqlite-vec); the provider is a stub
  * that records calls and returns deterministic vectors so we can verify
@@ -26,7 +24,6 @@ import {
   type ExpandedQuery,
 } from "../src/store.js";
 import {
-  AutoFallbackEmbeddingProvider,
   CircuitOpenError,
   type EmbeddingProvider,
   type ProviderEmbedding,
@@ -125,7 +122,7 @@ beforeEach(() => {
     .run("hashA", "Alpha document body about query encoding via remote provider.", now);
   store.db
     .prepare(`INSERT INTO content (hash, doc, created_at) VALUES (?, ?, ?)`)
-    .run("hashB", "Beta document body about fallback chain semantics.", now);
+    .run("hashB", "Beta document body about commercial API failure semantics.", now);
   store.db
     .prepare(`INSERT INTO documents (hash, collection, path, title, created_at, modified_at, active) VALUES (?, ?, ?, ?, ?, ?, ?)`)
     .run("hashA", "test", "alpha.md", "Alpha", now, now, 1);
@@ -162,8 +159,7 @@ describe("searchVec with EmbeddingProvider", () => {
   test("encodes the query through the provider when supplied", async () => {
     const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
 
-    // Sanity: store.llm is not set; if searchVec touched local llama-cpp
-    // it would fail (no model loaded). Provider routing must be exclusive.
+    // Provider routing must be exclusive.
     const results = await searchVec(
       store.db, "hello", "embeddinggemma", 10,
       undefined, undefined, undefined, provider,
@@ -177,7 +173,7 @@ describe("searchVec with EmbeddingProvider", () => {
     expect(filepaths).toEqual(["qmd://test/alpha.md", "qmd://test/beta.md"]);
   });
 
-  test("provider mode does not access the local llama-cpp instance", async () => {
+  test("provider mode does not access the compatibility adapter", async () => {
     const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
 
     // If anything touches `store.llm` while the provider is set, the proxy
@@ -198,38 +194,24 @@ describe("searchVec with EmbeddingProvider", () => {
     expect(results.length).toBeGreaterThan(0);
   });
 
-  test("survives transient primary failure via AutoFallback", async () => {
-    const primary = new CircuitOpenProvider("embeddinggemma");
-    const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
-    const wrapped = new AutoFallbackEmbeddingProvider({
-      primary,
-      fallback,
-      warn: () => { /* swallow noisy WARN in tests */ },
-    });
+  test("does not select a model fallback when the provider circuit is open", async () => {
+    const provider = new CircuitOpenProvider("embeddinggemma");
 
-    const results = await searchVec(
-      store.db, "fallback test", "embeddinggemma", 10,
-      undefined, undefined, undefined, wrapped,
-    );
+    await expect(searchVec(
+      store.db, "provider failure", "embeddinggemma", 10,
+      undefined, undefined, undefined, provider,
+    )).rejects.toThrow(/remote down/);
 
-    expect(primary.embedCalls).toBe(1);
-    expect(fallback.embedCalls).toBe(1);
-    expect(results.length).toBeGreaterThan(0);
+    expect(provider.embedCalls).toBe(1);
   });
 
-  test("surfaces error when both primary AND fallback fail", async () => {
-    const primary = new AlwaysFailProvider("embeddinggemma");
-    const fallback = new AlwaysFailProvider("embeddinggemma");
-    const wrapped = new AutoFallbackEmbeddingProvider({
-      primary,
-      fallback,
-      warn: () => { /* swallow */ },
-    });
+  test("surfaces a commercial provider error without fallback", async () => {
+    const provider = new AlwaysFailProvider("embeddinggemma");
 
     await expect(
       searchVec(
         store.db, "doomed", "embeddinggemma", 10,
-        undefined, undefined, undefined, wrapped,
+        undefined, undefined, undefined, provider,
       ),
     ).rejects.toThrow(/backend unreachable/);
   });
@@ -241,7 +223,7 @@ describe("structuredSearch with EmbeddingProvider", () => {
   test("uses provider.embedBatch for vec/hyde sub-queries", async () => {
     const provider = new FixedProvider("embeddinggemma", FIXED_VEC);
 
-    // Deny access to the local llama-cpp — proves the provider path is exclusive.
+    // Deny access to the compatibility adapter to prove provider exclusivity.
     store.llm = new Proxy({}, {
       get(_target, prop) {
         throw new Error(
@@ -251,12 +233,12 @@ describe("structuredSearch with EmbeddingProvider", () => {
     }) as never;
 
     const queries: ExpandedQuery[] = [
-      { type: "vec", query: "what is the fallback chain about" },
-      { type: "hyde", query: "Fallback chains route around primary failure transparently." },
+      { type: "vec", query: "what is the commercial API failure policy" },
+      { type: "hyde", query: "Commercial provider failures remain explicit." },
     ];
 
     const results = await structuredSearch(store, queries, {
-      skipRerank: true, // reranker uses local llm — skip in this isolation test
+      skipRerank: true,
       embedProvider: provider,
     });
 
@@ -266,53 +248,32 @@ describe("structuredSearch with EmbeddingProvider", () => {
     expect(results.length).toBeGreaterThan(0);
   });
 
-  test("AutoFallback covers structuredSearch query batch", async () => {
-    const primary = new CircuitOpenProvider("embeddinggemma");
-    const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
-    const wrapped = new AutoFallbackEmbeddingProvider({
-      primary,
-      fallback,
-      warn: () => { /* swallow */ },
-    });
+  test("structuredSearch propagates an open provider circuit", async () => {
+    const provider = new CircuitOpenProvider("embeddinggemma");
 
     const queries: ExpandedQuery[] = [
-      { type: "vec", query: "fallback test" },
+      { type: "vec", query: "provider failure" },
     ];
 
-    const results = await structuredSearch(store, queries, {
+    await expect(structuredSearch(store, queries, {
       skipRerank: true,
-      embedProvider: wrapped,
-    });
+      embedProvider: provider,
+    })).rejects.toThrow(/remote down/);
 
-    expect(primary.embedBatchCalls).toBe(1);
-    expect(fallback.embedBatchCalls).toBe(1);
-    expect(results.length).toBeGreaterThan(0);
+    expect(provider.embedBatchCalls).toBe(1);
   });
 
-  test("structuredSearch degrades to empty results when both providers fail (batch path)", async () => {
-    // AutoFallback.embedBatch is contract-bound to return nulls on total
-    // failure (graceful degradation in batch mode — see autofallback.ts
-    // onTotalFail). structuredSearch then has no embeddings to query
-    // sqlite-vec with and returns []. This is the documented behavior;
-    // searchVec (single-embed path) is the one that surfaces a thrown
-    // error to the caller, see the test above.
-    const primary = new AlwaysFailProvider("embeddinggemma");
-    const fallback = new AlwaysFailProvider("embeddinggemma");
-    const wrapped = new AutoFallbackEmbeddingProvider({
-      primary,
-      fallback,
-      warn: () => { /* swallow */ },
-    });
+  test("structuredSearch surfaces a provider batch failure", async () => {
+    const provider = new AlwaysFailProvider("embeddinggemma");
 
     const queries: ExpandedQuery[] = [
       { type: "vec", query: "doomed" },
     ];
 
-    const results = await structuredSearch(store, queries, {
+    await expect(structuredSearch(store, queries, {
       skipRerank: true,
-      embedProvider: wrapped,
-    });
-    expect(results).toEqual([]);
+      embedProvider: provider,
+    })).rejects.toThrow(/backend unreachable/);
   });
 });
 
@@ -346,36 +307,26 @@ describe("vectorSearchQuery with EmbeddingProvider", () => {
     expect(results.length).toBeGreaterThan(0);
   });
 
-  test("AutoFallback rescues vectorSearchQuery from primary failure", async () => {
-    const primary = new CircuitOpenProvider("embeddinggemma");
-    const fallback = new FixedProvider("embeddinggemma", FIXED_VEC);
-    const wrapped = new AutoFallbackEmbeddingProvider({
-      primary,
-      fallback,
-      warn: () => { /* swallow */ },
-    });
+  test("vectorSearchQuery does not select a model fallback", async () => {
+    const provider = new CircuitOpenProvider("embeddinggemma");
 
     store.expandQuery = async () => [];
 
-    const results = await vectorSearchQuery(store, "fallback path", {
+    await expect(vectorSearchQuery(store, "provider failure", {
       minScore: 0,
-      embedProvider: wrapped,
-    });
+      embedProvider: provider,
+    })).rejects.toThrow(/remote down/);
 
-    expect(primary.embedCalls).toBeGreaterThanOrEqual(1);
-    expect(fallback.embedCalls).toBeGreaterThanOrEqual(1);
-    expect(results.length).toBeGreaterThan(0);
+    expect(provider.embedCalls).toBeGreaterThanOrEqual(1);
   });
 });
 
 // ─────────────────────────── Backward compat ────────────────────────────────
 
-describe("backward compat — no provider supplied", () => {
-  test("searchVec without provider uses precomputed embedding path (no llm needed)", async () => {
+describe("precomputed vector path", () => {
+  test("searchVec with a precomputed embedding needs no model operation", async () => {
     // When the caller passes `precomputedEmbedding`, searchVec must not
-    // touch any embedding backend at all — neither local nor provider.
-    // This is the cheapest backward-compat smoke test we can run without
-    // loading node-llama-cpp.
+    // touch any embedding backend.
     store.llm = new Proxy({}, {
       get(_target, prop) {
         throw new Error(`store.llm.${String(prop)} accessed unexpectedly`);

+ 2 - 2
test/eval.test.ts

@@ -156,7 +156,7 @@ describe("BM25 Search (FTS)", () => {
 // Vector Search Tests - Requires embedding model
 // =============================================================================
 
-describe.skipIf(!!process.env.CI)("Vector Search", () => {
+describe.skip("Vector Search requires a provisioned commercial embedding adapter", () => {
   let store: ReturnType<typeof createStore>;
   let db: Database;
   let hasEmbeddings = false;
@@ -268,7 +268,7 @@ describe.skipIf(!!process.env.CI)("Vector Search", () => {
 // Hybrid Search (RRF) Tests - Combines BM25 + Vector
 // =============================================================================
 
-describe.skipIf(!!process.env.CI)("Hybrid Search (RRF)", () => {
+describe.skip("Hybrid Search requires provisioned commercial model adapters", () => {
   let store: ReturnType<typeof createStore>;
   let db: Database;
   let hasVectors = false;

+ 34 - 38
test/llm.test.ts

@@ -1,10 +1,6 @@
 /**
- * llm.test.ts - Unit tests for the LLM abstraction layer (node-llama-cpp)
- *
- * Run with: bun test src/llm.test.ts
- *
- * These tests require the actual models to be downloaded. Run the embed or
- * rerank functions first to trigger model downloads.
+ * Compatibility-adapter tests for the commercial-only model policy.
+ * Historical local-runtime suites remain permanently skipped below.
  */
 
 import { describe, test, expect, beforeAll, afterAll, vi } from "vitest";
@@ -40,12 +36,12 @@ describe("Default LlamaCpp Singleton", () => {
 // =============================================================================
 
 describe("LlamaCpp.modelExists", () => {
-  test("returns exists:true for HuggingFace model URIs", async () => {
+  test("does not resolve remote model artifact identifiers", async () => {
     const llm = getDefaultLlamaCpp();
-    const result = await llm.modelExists("hf:org/repo/model.gguf");
+    const result = await llm.modelExists("remote-model-artifact");
 
-    expect(result.exists).toBe(true);
-    expect(result.name).toBe("hf:org/repo/model.gguf");
+    expect(result.exists).toBe(false);
+    expect(result.name).toBe("remote-model-artifact");
   });
 
   test("returns exists:false for non-existent local paths", async () => {
@@ -57,7 +53,7 @@ describe("LlamaCpp.modelExists", () => {
   });
 });
 
-describe("LlamaCpp expand context size config", () => {
+describe.skip("historical local expand context configuration", () => {
   const defaultExpandContextSize = 2048;
 
   test("uses default expand context size when no config or env is set", () => {
@@ -119,7 +115,7 @@ describe("LlamaCpp expand context size config", () => {
   });
 });
 
-describe("LlamaCpp model resolution (config > env > default)", () => {
+describe.skip("historical local model resolution", () => {
   const HARDCODED_EMBED = "hf:ggml-org/embeddinggemma-300M-GGUF/embeddinggemma-300M-Q8_0.gguf";
   const HARDCODED_RERANK = "hf:ggml-org/Qwen3-Reranker-0.6B-Q8_0-GGUF/qwen3-reranker-0.6b-q8_0.gguf";
   const HARDCODED_GENERATE = "hf:tobil/qmd-query-expansion-1.7B-gguf/qmd-query-expansion-1.7B-q4_k_m.gguf";
@@ -167,15 +163,15 @@ describe("LlamaCpp model resolution (config > env > default)", () => {
 // QMD_DISABLE_LOCAL_LLM + remote-only auto-CPU (i-c28wngnd)
 // =============================================================================
 
-describe("isLocalLlmDisabled (QMD_DISABLE_LOCAL_LLM)", () => {
-  test("returns false when env var is unset", () => {
-    expect(isLocalLlmDisabled({})).toBe(false);
-    expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: undefined })).toBe(false);
+describe("local model runtime policy", () => {
+  test("remains disabled when env var is unset", () => {
+    expect(isLocalLlmDisabled({})).toBe(true);
+    expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: undefined })).toBe(true);
   });
 
-  test("returns false when env var is empty / whitespace", () => {
-    expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: "" })).toBe(false);
-    expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: "   " })).toBe(false);
+  test("remains disabled for empty or whitespace values", () => {
+    expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: "" })).toBe(true);
+    expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: "   " })).toBe(true);
   });
 
   test("returns true for canonical truthy values", () => {
@@ -184,16 +180,16 @@ describe("isLocalLlmDisabled (QMD_DISABLE_LOCAL_LLM)", () => {
     }
   });
 
-  test("returns false for canonical falsy values", () => {
+  test("cannot be re-enabled by legacy falsy values", () => {
     for (const v of ["0", "false", "no", "off", "FALSE", "No"]) {
-      expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: v })).toBe(false);
+      expect(isLocalLlmDisabled({ QMD_DISABLE_LOCAL_LLM: v })).toBe(true);
     }
   });
 });
 
-describe("resolveLlamaGpuMode (QMD_LLAMA_GPU + QMD_EMBED_ENDPOINT)", () => {
-  test("returns 'auto' for empty env (legacy local-only setup)", () => {
-    expect(resolveLlamaGpuMode({})).toBe("auto");
+describe("local model GPU policy", () => {
+  test("returns CPU-disabled mode for empty env", () => {
+    expect(resolveLlamaGpuMode({})).toBe("cpu");
   });
 
   test("explicit QMD_LLAMA_GPU=off|none|0|disabled forces CPU", () => {
@@ -202,9 +198,9 @@ describe("resolveLlamaGpuMode (QMD_LLAMA_GPU + QMD_EMBED_ENDPOINT)", () => {
     }
   });
 
-  test("explicit QMD_LLAMA_GPU=auto|on|true preserves probe", () => {
+  test("legacy GPU enable values cannot re-enable probing", () => {
     for (const v of ["auto", "on", "true", "Auto"]) {
-      expect(resolveLlamaGpuMode({ QMD_LLAMA_GPU: v })).toBe("auto");
+      expect(resolveLlamaGpuMode({ QMD_LLAMA_GPU: v })).toBe("cpu");
     }
   });
 
@@ -214,27 +210,27 @@ describe("resolveLlamaGpuMode (QMD_LLAMA_GPU + QMD_EMBED_ENDPOINT)", () => {
     ).toBe("cpu");
   });
 
-  test("explicit QMD_LLAMA_GPU=auto OVERRIDES QMD_EMBED_ENDPOINT auto-CPU", () => {
+  test("legacy GPU auto cannot override commercial endpoint policy", () => {
     expect(
       resolveLlamaGpuMode({
         QMD_LLAMA_GPU: "auto",
         QMD_EMBED_ENDPOINT: "http://models:8082",
       }),
-    ).toBe("auto");
+    ).toBe("cpu");
   });
 
-  test("empty QMD_EMBED_ENDPOINT does not trigger auto-CPU", () => {
-    expect(resolveLlamaGpuMode({ QMD_EMBED_ENDPOINT: "" })).toBe("auto");
-    expect(resolveLlamaGpuMode({ QMD_EMBED_ENDPOINT: "   " })).toBe("auto");
+  test("empty endpoint still leaves local GPU probing disabled", () => {
+    expect(resolveLlamaGpuMode({ QMD_EMBED_ENDPOINT: "" })).toBe("cpu");
+    expect(resolveLlamaGpuMode({ QMD_EMBED_ENDPOINT: "   " })).toBe("cpu");
   });
 
-  test("unknown QMD_LLAMA_GPU values fall back to 'auto' (preserve legacy probe)", () => {
-    expect(resolveLlamaGpuMode({ QMD_LLAMA_GPU: "vulkan" })).toBe("auto");
-    expect(resolveLlamaGpuMode({ QMD_LLAMA_GPU: "cuda" })).toBe("auto");
+  test("unknown GPU values cannot enable probing", () => {
+    expect(resolveLlamaGpuMode({ QMD_LLAMA_GPU: "vulkan" })).toBe("cpu");
+    expect(resolveLlamaGpuMode({ QMD_LLAMA_GPU: "cuda" })).toBe("cpu");
   });
 });
 
-describe("LlamaCpp.ensureLlama() + QMD_DISABLE_LOCAL_LLM", () => {
+describe.skip("historical local runtime initialization", () => {
   test("throws with actionable error when QMD_DISABLE_LOCAL_LLM=1", async () => {
     const prev = process.env.QMD_DISABLE_LOCAL_LLM;
     process.env.QMD_DISABLE_LOCAL_LLM = "1";
@@ -262,7 +258,7 @@ describe("LlamaCpp.ensureLlama() + QMD_DISABLE_LOCAL_LLM", () => {
   });
 });
 
-describe("LlamaCpp rerank deduping", () => {
+describe.skip("historical local reranking", () => {
   test("deduplicates identical document texts before scoring", async () => {
     const llm = new LlamaCpp({}) as any;
     llm._ciMode = false; // allow unit test even in CI (mocked, no real models)
@@ -298,7 +294,7 @@ describe("LlamaCpp rerank deduping", () => {
 // Integration Tests (require actual models)
 // =============================================================================
 
-describe.skipIf(!!process.env.CI)("LlamaCpp Integration", () => {
+describe.skip("historical local runtime integration", () => {
   // Use the singleton to avoid multiple Metal contexts
   const llm = getDefaultLlamaCpp();
 
@@ -701,7 +697,7 @@ describe.skipIf(!!process.env.CI)("LlamaCpp Integration", () => {
 // Session Management Tests
 // =============================================================================
 
-describe.skipIf(!!process.env.CI)("LLM Session Management", () => {
+describe.skip("historical local session management", () => {
   describe("withLLMSession", () => {
     test("session provides access to LLM operations", async () => {
       const result = await withLLMSession(async (session) => {

+ 2 - 2
test/mcp.test.ts

@@ -323,7 +323,7 @@ describe("MCP Server", () => {
   // searchVec (Vector similarity search)
   // ===========================================================================
 
-  describe.skipIf(!!process.env.CI)("searchVec (vector similarity)", () => {
+  describe.skip("searchVec requires a provisioned commercial embedding adapter", () => {
     test("returns results for semantic query", async () => {
       const results = await searchVec(testDb, "project documentation", DEFAULT_EMBED_MODEL, 10);
       expect(results.length).toBeGreaterThan(0);
@@ -349,7 +349,7 @@ describe("MCP Server", () => {
   // hybridQuery (query expansion + reranking)
   // ===========================================================================
 
-  describe.skipIf(!!process.env.CI)("hybridQuery (expansion + reranking)", () => {
+  describe.skip("hybridQuery requires provisioned commercial model adapters", () => {
     test("expands query with typed variations", async () => {
       const expanded = await expandQuery("api documentation", DEFAULT_QUERY_MODEL, testDb);
       // Returns ExpandedQuery[] — typed expansions, original excluded

+ 18 - 21
test/sdk.test.ts

@@ -21,8 +21,8 @@ import {
   type LexSearchOptions,
   type VectorSearchOptions,
   type ExpandQueryOptions,
+  type EmbeddingProvider,
 } from "../src/index.js";
-import { setDefaultLlamaCpp } from "../src/llm.js";
 
 // =============================================================================
 // Test Helpers
@@ -615,7 +615,7 @@ describe("search (unified API)", () => {
   });
 
   // Tests below use search({ query: ... }) which triggers LLM query expansion
-  describe.skipIf(!!process.env.CI)("with LLM query expansion", () => {
+  describe.skip("requires a provisioned commercial generation adapter", () => {
     test("search() with query and rerank:false returns results", async () => {
       const results = await store.search({ query: "authentication", rerank: false });
       expect(results.length).toBeGreaterThan(0);
@@ -926,32 +926,34 @@ describe("update", () => {
 });
 
 describe("embed", () => {
-  function createFakeTokenizer() {
-    return {
-      async tokenize(text: string) {
-        return new Array(Math.max(1, Math.ceil(text.length / 16))).fill(1);
-      },
-    };
-  }
-
-  function createFakeEmbedLlm() {
+  function createFakeCommercialProvider(): EmbeddingProvider & { embedBatchCalls: string[][] } {
     const embedBatchCalls: string[][] = [];
     return {
+      kind: "openai",
       embedBatchCalls,
+      getModelId: () => "commercial-test-embedding-v1",
+      getDimensions: () => 3,
+      healthcheck: async () => ({
+        ok: true,
+        model: "commercial-test-embedding-v1",
+        dimensions: 3,
+      }),
       async embed(_text: string) {
-        return { embedding: [0.1, 0.2, 0.3], model: "fake-embed" };
+        return { embedding: [0.1, 0.2, 0.3], model: "commercial-test-embedding-v1" };
       },
       async embedBatch(texts: string[]) {
         embedBatchCalls.push([...texts]);
         return texts.map((_text, index) => ({
           embedding: [index + 1, index + 2, index + 3],
-          model: "fake-embed",
+          model: "commercial-test-embedding-v1",
         }));
       },
+      async dispose() {},
     };
   }
 
   test("store.embed forwards batch limit options", async () => {
+    const fakeProvider = createFakeCommercialProvider();
     const store = await createStore({
       dbPath: freshDbPath(),
       config: {
@@ -959,12 +961,9 @@ describe("embed", () => {
           docs: { path: docsDir, pattern: "**/*.md" },
         },
       },
+      embedProvider: fakeProvider,
     });
 
-    const fakeLlm = createFakeEmbedLlm();
-    setDefaultLlamaCpp(createFakeTokenizer() as any);
-    store.internal.llm = fakeLlm as any;
-
     try {
       await store.update();
       const result = await store.embed({
@@ -972,12 +971,11 @@ describe("embed", () => {
         maxBatchBytes: 1024 * 1024,
       });
 
-      expect(fakeLlm.embedBatchCalls).toHaveLength(3);
-      expect(fakeLlm.embedBatchCalls.map(call => call.length)).toEqual([1, 1, 1]);
+      expect(fakeProvider.embedBatchCalls).toHaveLength(3);
+      expect(fakeProvider.embedBatchCalls.map(call => call.length)).toEqual([1, 1, 1]);
       expect(result.docsProcessed).toBe(3);
       expect(result.chunksEmbedded).toBe(3);
     } finally {
-      setDefaultLlamaCpp(null);
       await store.close();
     }
   });
@@ -992,7 +990,6 @@ describe("embed", () => {
       await expect(store.embed({ maxDocsPerBatch: 0 })).rejects.toThrow("maxDocsPerBatch");
       await expect(store.embed({ maxBatchBytes: 0 })).rejects.toThrow("maxBatchBytes");
     } finally {
-      setDefaultLlamaCpp(null);
       await store.close();
     }
   });

+ 2 - 2
test/store.test.ts

@@ -496,7 +496,7 @@ describe("Document Chunking", () => {
   });
 });
 
-describe.skipIf(!!process.env.CI)("Token-based Chunking", () => {
+describe.skip("Token-based Chunking requires a provisioned commercial tokenizer adapter", () => {
   test("chunkDocumentByTokens returns single chunk for small documents", async () => {
     const content = "This is a small document.";
     const chunks = await chunkDocumentByTokens(content, 900, 135);
@@ -2346,7 +2346,7 @@ describe("Integration", () => {
 // LlamaCpp Integration Tests (using real local models)
 // =============================================================================
 
-describe.skipIf(!!process.env.CI)("LlamaCpp Integration", () => {
+describe.skip("historical local model integration", () => {
   test("searchVec returns empty when no vector index", async () => {
     const store = await createTestStore();
     const collectionName = await createTestCollection();