Files
gbrain/src/core/embedding.ts
T
5c49225e4b v0.42.45.0 feat(sync): delta-aware cost estimator — stop wedging the daily cron (#2139) (#2224)
* feat(core): shared computeSyncDelta + spend-posture module (#2139)

sync-delta.ts: ONE implementation of "what changed since last_commit",
consumed by both the sync executor and the inline cost estimator so the
gate's dollar figure can't drift from what the sync imports.

spend-posture.ts: spend.posture config + parseUsdLimit/formatUsdLimit
off-switch parsing (off/unlimited/none → Infinity; undefined at the budget
boundary so ledger rows never serialize null).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(sync): delta-aware cost estimator + non-TTY auto-defer + per-source failure acks (#2139)

The inline-embed cost gate was a ~400x phantom: it priced the entire tree
whenever the working tree was dirty (always, on an active brain), then blocked
the daily cron with exit 2. Now:

- performSyncInner + the estimator both route through computeSyncDelta, so the
  estimate mirrors execution (fetch-first delta; dirty-but-caught-up tree → $0).
- shouldBlockSync is posture-aware; non-TTY above floor AUTO-DEFERS embeds to
  capped backfill jobs (exit 0) instead of wedging — single shared
  runInlineCostGate on both --all and single-source paths.
- --full prices delta + stale backlog (full sync sweeps it inline).
- off/unlimited on the cost knobs; tokenmax bypasses the backfill cap (still
  ledgered) but never the cooldown.
- --skip-failed/--retry-failed scoped per source; the D15 parallel refusal is
  lifted (the #1939 ledger is per-source + lock-serialized).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(config): register spend-control keys + validate spend.posture (#2139)

Adds spend.posture + the five previously --force-only spend knobs to
KNOWN_CONFIG_KEYS so `config set` accepts them directly (removes the
archaeology the issue complained about), and rejects invalid spend.posture
values at set time with a paste-ready hint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(reindex,enrich,onboard): spend.posture across the remaining cost gates (#2139)

reindex-code: tokenmax makes the cost gate informational; --max-cost accepts
off/unlimited. enrich + onboard --auto: tokenmax lifts the refuse-without-cap
guardrail and runs UNCAPPED (spend still ledgered by BudgetTracker). Explicit
--max-usd always wins over posture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test: cost-gate, delta estimator, spend-posture, off-switch coverage (#2139)

New sync-delta + sync-cost-estimate unit suites; rewritten cost-gate serial
tests (auto-defer instead of exit 2, posture, off-switch, format split,
single-source); parseUsdLimit/posture-aware shouldBlockSync; backfill cap-off
+ tokenmax-bypass + cooldown-still-refuses; config known-key acceptance.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(spend-controls): single spend-control surface + ref-map + follow-up TODOs (#2139)

New docs/operations/spend-controls.md (every gate, key, default, off switch,
posture interaction); CLAUDE.md reference-map row; two P3 follow-up TODOs
(measured chunk-count gating, per-source defer granularity). llms bundles
regenerated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(spend): SSRF-harden estimator fetch + complete off/uncapped across reindex/enrich/onboard (#2139)

Ship-stage codex pre-landing review caught four P1s in the secondary cost gates:

- The delta estimator's fetch-first ran `git fetch` through the plain git()
  helper, bypassing the GIT_SSRF_FLAGS + GIT_TERMINAL_PROMPT=0 hardening that
  real sync uses. Added `fetchRemote()` to git-remote.ts (same flags as
  pullRepo) and route the estimator through it — a cost preview / dry-run can
  no longer hit a remote through a less-protected path.
- `reindex --max-cost off`, `enrich --max-usd off`, `onboard --auto --max-usd
  off` were parsed but didn't actually proceed/uncap. Now: explicit off (and
  spend.posture=tokenmax) proceed past the confirmation/missing-cap refusal AND
  run uncapped. enrich threads an Infinity sentinel mapped to "no BudgetTracker
  ceiling" (never raw Infinity → no null in audit rows); reindex/onboard use
  their native undefined=uncapped path. Spend still ledgered.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore: bump version and changelog (v0.42.45.0)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(KEY_FILES): update sync/embedding/git-remote/reindex entries to post-#2139 truth

document-release pass: the cost-gate entries described the pre-#2139 behavior
(full-tree-ceiling estimator, --skip-failed-rejects-under-parallel, exit-2
confirmation gate). Updated to current truth — delta-aware estimator via the
shared computeSyncDelta, per-source failure acks under parallel, non-TTY
auto-defer (no exit 2), posture-aware shouldBlockSync. Added entries for the
two new core modules (sync-delta.ts, spend-posture.ts) + fetchRemote on
git-remote.ts + reindex --max-cost off.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 15:08:47 -07:00

241 lines
9.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Embedding Service — v0.14+ thin delegation to src/core/ai/gateway.ts.
*
* The gateway handles provider resolution, retry, error normalization, and
* dimension-parameter passthrough (preserving existing 1536-dim brains).
*/
import {
embed as gatewayEmbed,
embedOne as gatewayEmbedOne,
embedQuery as gatewayEmbedQuery,
getEmbeddingModel as gatewayGetModel,
getEmbeddingDimensions as gatewayGetDims,
} from './ai/gateway.ts';
import { lookupEmbeddingPrice } from './embedding-pricing.ts';
// v0.27.1: re-export multimodal embedding so callers can pull both text and
// image embedding APIs from `src/core/embedding`. import-image-file consumes
// embedMultimodal directly.
//
// v0.36 cross-modal wave: query-side multimodal embedding (text and image
// variants) for hybridSearch routing image-intent queries to the multimodal
// column. embedMultimodalSafe is the partial-failure variant Phase 3 reindex
// uses to make forward progress on transient batch failures.
export {
embedMultimodal,
embedMultimodalSafe,
embedQueryMultimodal,
embedQueryMultimodalImage,
} from './ai/gateway.ts';
export type {
MultimodalInput,
EmbedMultimodalOpts,
MultimodalBatchResult,
} from './ai/types.ts';
/** Embed one text (document-side for asymmetric providers). */
export async function embed(text: string): Promise<Float32Array> {
return gatewayEmbedOne(text);
}
/**
* v0.35.0.0+: embed a single text on the QUERY side. For asymmetric providers
* (ZE zembed-1, Voyage v3+) this routes `input_type: 'query'` through the
* embed seam so the provider returns query-side vectors. For symmetric
* providers (OpenAI text-3, DashScope, Zhipu) the field is dropped — no
* behavior change. Used by hybrid.ts on the search hot path.
*
* v0.36 (D10): optional `embeddingModel` + `dimensions` overrides so the
* dynamic-embedding-column path can embed via the column's provider rather
* than the globally-configured default. Bare `embedQuery(text)` preserves
* pre-v0.36 behavior.
*/
export async function embedQuery(
text: string,
opts?: { embeddingModel?: string; dimensions?: number; abortSignal?: AbortSignal },
): Promise<Float32Array> {
return gatewayEmbedQuery(text, opts);
}
export interface EmbedBatchOptions {
/**
* Optional callback fired after each sub-batch completes. CLI wrappers
* tick a reporter; Minion handlers can call job.updateProgress here.
*/
onBatchComplete?: (done: number, total: number) => void;
/**
* v0.33.4 (D8): propagate the caller's `AbortSignal` into Vercel AI SDK's
* `embedMany({abortSignal})` so a wall-clock budget can cancel mid-fetch.
* Without this, a worker stuck mid-HTTP on a ~30s OpenAI timeout ignores
* the budget until the fetch resolves.
*/
abortSignal?: AbortSignal;
/**
* v0.33.4 (D4a): cap on AI SDK's per-call retries. Default in `embedMany`
* is 2 (so up to 3 attempts). Pass `0` from higher-level wrappers that
* own their own retry policy, otherwise wrapper × SDK retries stack
* (e.g. 3 SDK attempts × 5 wrapper attempts = 15 cycles per embedBatch)
* and amplify rate-limit pressure.
*/
maxRetries?: number;
}
/**
* Embed a batch of texts via the gateway. Sub-batches of 100 so upstream
* progress callbacks fire incrementally on large imports. The gateway owns
* adaptive batch splitting and per-recipe token-budget logic; this paginator
* is purely about progress-callback granularity.
*/
const BATCH_SIZE = 100;
export async function embedBatch(
texts: string[],
options: EmbedBatchOptions = {},
): Promise<Float32Array[]> {
if (!texts || texts.length === 0) return [];
// Build the gateway-call passthrough once; undefined fields stay undefined
// so non-opt-in callers see unchanged pre-v0.33.4 behavior.
const gwOpts = {
...(options.abortSignal !== undefined && { abortSignal: options.abortSignal }),
...(options.maxRetries !== undefined && { maxRetries: options.maxRetries }),
};
// Fast path: small batch, no progress callback — single gateway call.
if (texts.length <= BATCH_SIZE && !options.onBatchComplete) {
return gatewayEmbed(texts, gwOpts);
}
const results: Float32Array[] = [];
for (let i = 0; i < texts.length; i += BATCH_SIZE) {
const slice = texts.slice(i, i + BATCH_SIZE);
const out = await gatewayEmbed(slice, gwOpts);
results.push(...out);
options.onBatchComplete?.(results.length, texts.length);
}
return results;
}
/** Currently-configured embedding model (short form without provider prefix). */
export function getEmbeddingModelName(): string {
return gatewayGetModel().split(':').slice(1).join(':') || 'text-embedding-3-large';
}
/** Currently-configured embedding dimensions. */
export function getEmbeddingDimensions(): number {
return gatewayGetDims();
}
// Back-compat exports for tests that imported these from v0.13.
export const EMBEDDING_MODEL = 'text-embedding-3-large';
export const EMBEDDING_DIMENSIONS = 1536;
/**
* USD cost per 1k tokens for text-embedding-3-large. Retained for back-compat
* with callers/tests that import it directly; new cost math resolves the
* ACTUAL configured model's rate via embedding-pricing.ts instead of assuming
* OpenAI. (Hardcoding this rate produced cost previews that named the wrong
* provider and over-stated spend ~2.6x when the brain ran on a cheaper model.)
*/
export const EMBEDDING_COST_PER_1K_TOKENS = 0.00013;
/**
* Resolve the price-per-1M-tokens for the currently-configured embedding
* model. Falls back to the OpenAI text-embedding-3-large rate only when the
* model is unknown to the pricing table.
*/
export function currentEmbeddingPricePerMTok(): number {
let modelString: string;
try {
modelString = gatewayGetModel(); // e.g. 'zeroentropyai:zembed-1'
} catch {
// Gateway not configured (e.g. unit tests, cost preview before connect).
// Fall back to the OpenAI text-embedding-3-large default rate.
return 0.13;
}
const hit = lookupEmbeddingPrice(modelString);
return hit.kind === 'known' ? hit.pricePerMTok : 0.13;
}
/**
* Compute USD cost estimate for embedding `tokens` at the CURRENT configured
* model's rate (not a hardcoded OpenAI rate).
*/
export function estimateEmbeddingCostUsd(tokens: number): number {
return (tokens / 1_000_000) * currentEmbeddingPricePerMTok();
}
/**
* Embedding provenance signature for the currently-configured model:
* `<provider:model>:<dims>` (e.g. `openai:text-embedding-3-large:1536`).
* Stamped onto `pages.embedding_signature` when a page's chunks are
* embedded so a later model/dimension swap can be detected as stale.
*
* Deliberately does NOT include the chunker version — chunker drift is
* already tracked per-page via `pages.chunker_version` (used by sync +
* doctor). This signature is strictly about the EMBEDDING space.
*
* Falls back to the OpenAI default signature when the gateway is
* unconfigured (unit-test context), matching the other estimator fallbacks.
*/
export function currentEmbeddingSignature(): string {
try {
return `${gatewayGetModel()}:${gatewayGetDims()}`;
} catch {
return `${EMBEDDING_MODEL}:${EMBEDDING_DIMENSIONS}`;
}
}
/**
* Whether a `gbrain sync --all` invocation will embed at sync time
* ('inline') or defer embedding to per-source `embed-backfill` minion jobs
* ('deferred'). Under federated_v2 the default path defers; the backfill
* jobs carry their own 10-min cooldown + $25/source/24h spend cap, so the
* sync-time cost gate only BLOCKS on the inline path. See sync.ts:2346
* (`effectiveNoEmbed`) — this mirrors that resolution exactly.
*/
export type SyncEmbedMode = 'deferred' | 'inline';
/**
* Resolve the embed mode from the same three signals sync.ts uses to
* compute `effectiveNoEmbed`. Single source of truth so the cost gate and
* the actual embed decision can never drift.
*
* effectiveNoEmbed = v2Enabled && !serialFlag && !noEmbed ? true : noEmbed
*
* Embed runs INLINE iff that resolves to false:
* - v2 off → inline (legacy synchronous embed)
* - v2 on + --serial + !--no-embed → inline
* - v2 on (parallel) → deferred (backfill jobs)
* - --no-embed (any path) → the caller skips the gate entirely;
* we report 'deferred' for completeness.
*/
export function willEmbedSynchronously(opts: {
v2Enabled: boolean;
serialFlag: boolean;
noEmbed: boolean;
}): SyncEmbedMode {
const effectiveNoEmbed =
opts.v2Enabled && !opts.serialFlag && !opts.noEmbed ? true : opts.noEmbed;
return effectiveNoEmbed ? 'deferred' : 'inline';
}
/**
* Pure cost-gate decision. The gate BLOCKS (prompt in TTY, auto-defer in
* non-TTY) only when embed runs inline AND the estimated spend exceeds the
* floor. Deferred mode NEVER blocks — the backfill cap is the real money gate,
* and blocking the cheap markdown import for cost the import doesn't
* synchronously incur is the bug this fix removes.
*
* v0.42.42.0 (#2139): `spend.posture=tokenmax` makes the gate INFORMATIONAL —
* the operator has declared cost isn't the constraint, so it never blocks
* (the caller prints the estimate and proceeds inline). An `off`/`unlimited`
* floor (Infinity) is likewise never exceeded.
*/
export function shouldBlockSync(
costUsd: number,
floorUsd: number,
mode: SyncEmbedMode,
posture: 'gated' | 'tokenmax' = 'gated',
): boolean {
if (posture === 'tokenmax') return false;
return mode === 'inline' && costUsd > floorUsd;
}