fix(edge): meter and size-cap knowledge embeddings in embed-chunks and search-knowledge
This commit is contained in:
parent
b35676c75c
commit
1afaea7214
12 changed files with 1111 additions and 270 deletions
25
server/supabase/functions/_shared/embedding-quota.fake.ts
Normal file
25
server/supabase/functions/_shared/embedding-quota.fake.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Test double for EmbeddingUsageStore (not a test file itself).
|
||||
import { EMBEDDING_QUOTA_FEATURE, type EmbeddingUsageStore } from './embedding-quota.ts'
|
||||
import type { PlanQuotaTier } from './core-contract.generated.ts'
|
||||
|
||||
/** In-memory daily_usage keyed by date; `today` is the date increments land on. */
|
||||
export class MemoryUsageStore implements EmbeddingUsageStore {
|
||||
rows = new Map<string, number>()
|
||||
failIncrement = false
|
||||
constructor(public tier: PlanQuotaTier, public today: string) {}
|
||||
readTier(): Promise<PlanQuotaTier> {
|
||||
return Promise.resolve(this.tier)
|
||||
}
|
||||
increment(_userId: string, feature: string, amount: number): Promise<number> {
|
||||
if (this.failIncrement) return Promise.reject(new Error('down'))
|
||||
if (feature !== EMBEDDING_QUOTA_FEATURE) return Promise.reject(new Error(`unexpected feature ${feature}`))
|
||||
const next = (this.rows.get(this.today) ?? 0) + amount
|
||||
this.rows.set(this.today, next)
|
||||
return Promise.resolve(next)
|
||||
}
|
||||
sumBetween(_userId: string, _feature: string, fromDate: string, beforeDate: string): Promise<number> {
|
||||
let sum = 0
|
||||
for (const [date, count] of this.rows) if (date >= fromDate && date < beforeDate) sum += count
|
||||
return Promise.resolve(sum)
|
||||
}
|
||||
}
|
||||
85
server/supabase/functions/_shared/embedding-quota.test.ts
Normal file
85
server/supabase/functions/_shared/embedding-quota.test.ts
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
import {
|
||||
EMBEDDING_LIMITS,
|
||||
EMBEDDING_QUOTA,
|
||||
embeddingUnits,
|
||||
firstOversizedChunk,
|
||||
planEmbeddingBatches,
|
||||
refundEmbeddingUnits,
|
||||
reserveEmbeddingUnits,
|
||||
usageWindowStart,
|
||||
} from './embedding-quota.ts'
|
||||
import { MemoryUsageStore } from './embedding-quota.fake.ts'
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) throw new Error(message)
|
||||
}
|
||||
|
||||
const NOW = new Date('2026-09-28T12:00:00Z')
|
||||
|
||||
Deno.test('units count every started 1,000 characters, at least one per input', () => {
|
||||
assert(embeddingUnits(['a']) === 1, 'short input')
|
||||
assert(embeddingUnits(['x'.repeat(1_000), 'x'.repeat(1_001)]) === 3, 'boundary rounding')
|
||||
})
|
||||
|
||||
Deno.test('oversized chunks are found before any spend', () => {
|
||||
const ok = { content: 'x'.repeat(EMBEDDING_LIMITS.maxChunkChars) }
|
||||
const big = { content: 'x'.repeat(EMBEDDING_LIMITS.maxChunkChars + 1) }
|
||||
assert(firstOversizedChunk([ok, ok]) === -1, 'at-limit chunk rejected')
|
||||
assert(firstOversizedChunk([ok, big]) === 1, 'oversized chunk missed')
|
||||
})
|
||||
|
||||
Deno.test('batches stay under the input and character caps', () => {
|
||||
const small = Array.from({ length: 250 }, (_, i) => ({ content: `c${i}` }))
|
||||
assert(planEmbeddingBatches(small).map((b) => b.length).join(',') === '100,100,50', 'input cap')
|
||||
const large = Array.from({ length: 30 }, () => ({ content: 'x'.repeat(8_000) }))
|
||||
const batches = planEmbeddingBatches(large)
|
||||
assert(batches.every((b) => b.reduce((s, c) => s + c.content.length, 0) <= EMBEDDING_LIMITS.maxBatchChars), 'char cap')
|
||||
assert(batches.flat().length === 30, 'chunks lost')
|
||||
})
|
||||
|
||||
Deno.test('weekly window covers the six days before today', () => {
|
||||
assert(usageWindowStart('weekly', NOW) === '2026-09-22', 'weekly start')
|
||||
assert(usageWindowStart('daily', NOW) === null, 'daily has no earlier window')
|
||||
})
|
||||
|
||||
Deno.test('free tier is capped weekly and a denied reservation is rolled back', async () => {
|
||||
const store = new MemoryUsageStore('free', '2026-09-28')
|
||||
store.rows.set('2026-09-25', EMBEDDING_QUOTA.free.limit - 10)
|
||||
const denied = await reserveEmbeddingUnits(store, 'u1', 11, NOW)
|
||||
assert(!denied.allowed, 'over-limit reservation was allowed')
|
||||
assert((store.rows.get('2026-09-28') ?? 0) === 0, 'denied units were kept')
|
||||
const allowed = await reserveEmbeddingUnits(store, 'u1', 10, NOW)
|
||||
assert(allowed.allowed && allowed.current === EMBEDDING_QUOTA.free.limit, 'exact fit denied')
|
||||
})
|
||||
|
||||
Deno.test('usage older than the window does not count', async () => {
|
||||
const store = new MemoryUsageStore('free', '2026-09-28')
|
||||
store.rows.set('2026-09-21', EMBEDDING_QUOTA.free.limit)
|
||||
assert((await reserveEmbeddingUnits(store, 'u1', 5, NOW)).allowed, 'stale usage counted')
|
||||
})
|
||||
|
||||
Deno.test('enterprise is unlimited but still counted', async () => {
|
||||
const store = new MemoryUsageStore('enterprise', '2026-09-28')
|
||||
const result = await reserveEmbeddingUnits(store, 'u1', 1_000_000, NOW)
|
||||
assert(result.allowed && store.rows.get('2026-09-28') === 1_000_000, 'unlimited tier not counted')
|
||||
})
|
||||
|
||||
Deno.test('refunds are capped by the reservation and skipped after the day rolls over', async () => {
|
||||
const store = new MemoryUsageStore('pro', '2026-09-28')
|
||||
const reservation = await reserveEmbeddingUnits(store, 'u1', 40, NOW)
|
||||
await refundEmbeddingUnits(store, 'u1', reservation, 1_000, NOW)
|
||||
assert(store.rows.get('2026-09-28') === 0, 'refund exceeded the reserved units')
|
||||
|
||||
const again = await reserveEmbeddingUnits(store, 'u1', 40, NOW)
|
||||
store.today = '2026-09-29'
|
||||
await refundEmbeddingUnits(store, 'u1', again, 40, new Date('2026-09-29T00:00:01Z'))
|
||||
assert(!store.rows.has('2026-09-29'), 'next-day refund credited the new day')
|
||||
})
|
||||
|
||||
Deno.test('a store failure during refund is swallowed', async () => {
|
||||
const store = new MemoryUsageStore('pro', '2026-09-28')
|
||||
const reservation = await reserveEmbeddingUnits(store, 'u1', 3, NOW)
|
||||
store.failIncrement = true
|
||||
await refundEmbeddingUnits(store, 'u1', reservation, 3, NOW)
|
||||
assert(store.rows.get('2026-09-28') === 3, 'usage changed')
|
||||
})
|
||||
250
server/supabase/functions/_shared/embedding-quota.ts
Normal file
250
server/supabase/functions/_shared/embedding-quota.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
// server/supabase/functions/_shared/embedding-quota.ts
|
||||
// Cost guard for the paid OpenAI embedding calls made by embed-chunks and
|
||||
// search-knowledge.
|
||||
//
|
||||
// Before this module both functions only checked the JWT: any signed-in account
|
||||
// could insert an unbounded number of knowledge_chunks rows (RLS only checks
|
||||
// document ownership, `content` is unbounded text) and have every one of them
|
||||
// embedded, repeatedly, and search-knowledge embedded a 4,000-char query per
|
||||
// call with no counter. stt/llm/realtime all go through quota.ts; embeddings
|
||||
// did not.
|
||||
//
|
||||
// Policy (pure): size caps + per-tier unit budget + batch planning.
|
||||
// Port: EmbeddingUsageStore (tier lookup + atomic counter).
|
||||
// Adapter: createSupabaseEmbeddingUsageStore (subscriptions + daily_usage
|
||||
// via the existing service-role `increment_daily_usage` RPC).
|
||||
//
|
||||
// TODO(plan-catalog): the per-tier budget below belongs in
|
||||
// packages/core/src/plan-catalog.ts `PLAN_QUOTA` (feature `knowledge_embedding`)
|
||||
// and should be read from core-contract.generated.ts once it is added there.
|
||||
|
||||
import type { createClient } from '@supabase/supabase-js'
|
||||
import type { PlanQuotaPeriod, PlanQuotaTier } from './core-contract.generated.ts'
|
||||
|
||||
/** daily_usage.feature key for embedding spend. */
|
||||
export const EMBEDDING_QUOTA_FEATURE = 'knowledge_embedding'
|
||||
|
||||
export const EMBEDDING_LIMITS = Object.freeze({
|
||||
/** Longest chunk accepted for embedding (the OpenAI per-input cap is ~8k tokens). */
|
||||
maxChunkChars: 8_000,
|
||||
/** Most chunks one document may have. Desktop chunking stops at 2,000 chunks. */
|
||||
maxChunksPerDocument: 2_000,
|
||||
/** Longest search query accepted. */
|
||||
maxQueryChars: 4_000,
|
||||
/** Inputs per provider request. */
|
||||
maxBatchInputs: 100,
|
||||
/** Characters per provider request (keeps a batch under the per-request token cap). */
|
||||
maxBatchChars: 100_000,
|
||||
/** One quota unit = up to this many characters of embedded text. */
|
||||
charsPerUnit: 1_000,
|
||||
})
|
||||
|
||||
export interface EmbeddingQuotaPolicy {
|
||||
/** Units per period. -1 = unlimited, 0 = not available. */
|
||||
limit: number
|
||||
period: PlanQuotaPeriod
|
||||
}
|
||||
|
||||
/**
|
||||
* Units (1 unit = up to 1,000 chars) per tier. Cloud knowledge is offered on
|
||||
* every tier (web/mobile have no paywall for it), so free gets a small weekly
|
||||
* budget instead of none: 5,000 units ≈ 5M chars ≈ 20 maximum-size mobile
|
||||
* documents a week.
|
||||
*/
|
||||
export const EMBEDDING_QUOTA: Readonly<Record<PlanQuotaTier, EmbeddingQuotaPolicy>> = Object.freeze({
|
||||
free: { limit: 5_000, period: 'weekly' },
|
||||
pro: { limit: 50_000, period: 'daily' },
|
||||
pro_plus: { limit: 150_000, period: 'daily' },
|
||||
team: { limit: 300_000, period: 'daily' },
|
||||
enterprise: { limit: -1, period: 'daily' },
|
||||
})
|
||||
|
||||
const QUOTA_TIERS: readonly PlanQuotaTier[] = ['free', 'pro', 'pro_plus', 'team', 'enterprise']
|
||||
|
||||
export function normalizeQuotaTier(value: unknown): PlanQuotaTier {
|
||||
return typeof value === 'string' && (QUOTA_TIERS as readonly string[]).includes(value)
|
||||
? value as PlanQuotaTier
|
||||
: 'free'
|
||||
}
|
||||
|
||||
export function embeddingPolicyFor(tier: PlanQuotaTier): EmbeddingQuotaPolicy {
|
||||
return EMBEDDING_QUOTA[tier] ?? { limit: 0, period: 'daily' }
|
||||
}
|
||||
|
||||
/** Units one text costs: every started 1,000 characters, at least 1. */
|
||||
export function embeddingUnitsFor(text: string): number {
|
||||
return Math.max(1, Math.ceil(text.length / EMBEDDING_LIMITS.charsPerUnit))
|
||||
}
|
||||
|
||||
export function embeddingUnits(texts: readonly string[]): number {
|
||||
return texts.reduce((sum, text) => sum + embeddingUnitsFor(text), 0)
|
||||
}
|
||||
|
||||
/** Index of the first chunk over `maxChunkChars`, or -1. */
|
||||
export function firstOversizedChunk(chunks: readonly { content: string }[]): number {
|
||||
return chunks.findIndex((chunk) => chunk.content.length > EMBEDDING_LIMITS.maxChunkChars)
|
||||
}
|
||||
|
||||
/** Split chunks into provider requests bounded by input count and characters. */
|
||||
export function planEmbeddingBatches<T extends { content: string }>(chunks: readonly T[]): T[][] {
|
||||
const batches: T[][] = []
|
||||
let current: T[] = []
|
||||
let chars = 0
|
||||
for (const chunk of chunks) {
|
||||
const size = chunk.content.length
|
||||
if (
|
||||
current.length > 0
|
||||
&& (current.length >= EMBEDDING_LIMITS.maxBatchInputs || chars + size > EMBEDDING_LIMITS.maxBatchChars)
|
||||
) {
|
||||
batches.push(current)
|
||||
current = []
|
||||
chars = 0
|
||||
}
|
||||
current.push(chunk)
|
||||
chars += size
|
||||
}
|
||||
if (current.length > 0) batches.push(current)
|
||||
return batches
|
||||
}
|
||||
|
||||
function isoDate(date: Date): string {
|
||||
return date.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** First date (inclusive) of the usage window before today, or null for a daily period. */
|
||||
export function usageWindowStart(period: PlanQuotaPeriod, now: Date): string | null {
|
||||
if (period !== 'weekly') return null
|
||||
const start = new Date(now.getTime())
|
||||
start.setUTCDate(start.getUTCDate() - 6)
|
||||
return isoDate(start)
|
||||
}
|
||||
|
||||
// ── Port ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface EmbeddingUsageStore {
|
||||
readTier(userId: string): Promise<PlanQuotaTier>
|
||||
/** Atomically add `amount` (may be negative) to today's counter; returns the new count. */
|
||||
increment(userId: string, feature: string, amount: number): Promise<number>
|
||||
/** Sum of counters with fromDate <= date < beforeDate. */
|
||||
sumBetween(userId: string, feature: string, fromDate: string, beforeDate: string): Promise<number>
|
||||
}
|
||||
|
||||
export interface EmbeddingReservation {
|
||||
allowed: boolean
|
||||
/** Units held by this reservation (0 when denied or nothing was requested). */
|
||||
units: number
|
||||
/** Usage in the current window including this reservation when allowed. */
|
||||
current: number
|
||||
limit: number
|
||||
period: PlanQuotaPeriod
|
||||
tier: PlanQuotaTier
|
||||
/** UTC date the units were counted on. */
|
||||
date: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve `units` before spending. The counter is incremented first (atomic in
|
||||
* the database) and rolled back when the window total then exceeds the limit,
|
||||
* so concurrent requests can never admit more than the limit between them.
|
||||
* Throws when the store fails: callers must fail closed.
|
||||
*/
|
||||
export async function reserveEmbeddingUnits(
|
||||
store: EmbeddingUsageStore,
|
||||
userId: string,
|
||||
units: number,
|
||||
now: Date = new Date(),
|
||||
): Promise<EmbeddingReservation> {
|
||||
const tier = await store.readTier(userId)
|
||||
const policy = embeddingPolicyFor(tier)
|
||||
const date = isoDate(now)
|
||||
const base = { limit: policy.limit, period: policy.period, tier, date }
|
||||
|
||||
if (units <= 0) return { ...base, allowed: true, units: 0, current: 0 }
|
||||
if (policy.limit === 0) return { ...base, allowed: false, units: 0, current: 0 }
|
||||
|
||||
const today = await store.increment(userId, EMBEDDING_QUOTA_FEATURE, units)
|
||||
if (policy.limit === -1) return { ...base, allowed: true, units, current: today }
|
||||
|
||||
const windowStart = usageWindowStart(policy.period, now)
|
||||
const earlier = windowStart
|
||||
? await store.sumBetween(userId, EMBEDDING_QUOTA_FEATURE, windowStart, date)
|
||||
: 0
|
||||
const used = earlier + today
|
||||
if (used > policy.limit) {
|
||||
await store.increment(userId, EMBEDDING_QUOTA_FEATURE, -units)
|
||||
return { ...base, allowed: false, units: 0, current: used - units }
|
||||
}
|
||||
return { ...base, allowed: true, units, current: used }
|
||||
}
|
||||
|
||||
/**
|
||||
* Give back units that were reserved but not spent. Best effort: a failure
|
||||
* leaves the user slightly over-counted, never under-counted. A refund after
|
||||
* the UTC day rolled over is skipped so it cannot credit the new day.
|
||||
*/
|
||||
export async function refundEmbeddingUnits(
|
||||
store: EmbeddingUsageStore,
|
||||
userId: string,
|
||||
reservation: EmbeddingReservation,
|
||||
units: number,
|
||||
now: Date = new Date(),
|
||||
): Promise<void> {
|
||||
const amount = Math.min(units, reservation.units)
|
||||
if (amount <= 0 || isoDate(now) !== reservation.date) return
|
||||
try {
|
||||
await store.increment(userId, EMBEDDING_QUOTA_FEATURE, -amount)
|
||||
} catch {
|
||||
// over-counting is the safe direction
|
||||
}
|
||||
}
|
||||
|
||||
export function quotaExceededBody(reservation: EmbeddingReservation, requested: number): Record<string, unknown> {
|
||||
return {
|
||||
error: 'quota_exceeded',
|
||||
feature: EMBEDDING_QUOTA_FEATURE,
|
||||
requested,
|
||||
current: reservation.current,
|
||||
limit: reservation.limit,
|
||||
period: reservation.period,
|
||||
tier: reservation.tier,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Supabase adapter ────────────────────────────────────────────────────
|
||||
|
||||
type SupabaseClient = ReturnType<typeof createClient>
|
||||
|
||||
export function createSupabaseEmbeddingUsageStore(client: SupabaseClient): EmbeddingUsageStore {
|
||||
return {
|
||||
async readTier(userId) {
|
||||
const { data, error } = await client
|
||||
.from('subscriptions')
|
||||
.select('tier')
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle()
|
||||
if (error) throw new Error('Failed to read subscription tier.')
|
||||
return normalizeQuotaTier((data as { tier?: unknown } | null)?.tier)
|
||||
},
|
||||
async increment(userId, feature, amount) {
|
||||
const { data, error } = await client.rpc('increment_daily_usage', {
|
||||
p_user_id: userId,
|
||||
p_feature: feature,
|
||||
p_amount: amount,
|
||||
})
|
||||
if (error || typeof data !== 'number') throw new Error('Failed to update embedding usage.')
|
||||
return data
|
||||
},
|
||||
async sumBetween(userId, feature, fromDate, beforeDate) {
|
||||
const { data, error } = await client
|
||||
.from('daily_usage')
|
||||
.select('count')
|
||||
.eq('user_id', userId)
|
||||
.eq('feature', feature)
|
||||
.gte('date', fromDate)
|
||||
.lt('date', beforeDate)
|
||||
if (error) throw new Error('Failed to read embedding usage.')
|
||||
const rows = (data ?? []) as Array<{ count: number | null }>
|
||||
return rows.reduce((sum, row) => sum + (row.count ?? 0), 0)
|
||||
},
|
||||
}
|
||||
}
|
||||
30
server/supabase/functions/_shared/json-response.ts
Normal file
30
server/supabase/functions/_shared/json-response.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// server/supabase/functions/_shared/json-response.ts
|
||||
// JSON response + auth-error mapping shared by the knowledge functions.
|
||||
|
||||
import { corsHeaders } from './cors.ts'
|
||||
import type { AuthError } from './auth.ts'
|
||||
|
||||
export function jsonResponse(status: number, body: Record<string, unknown>): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
export function isAuthError(error: unknown): error is AuthError {
|
||||
return Boolean(
|
||||
error
|
||||
&& typeof error === 'object'
|
||||
&& 'status' in error
|
||||
&& (error.status === 401 || error.status === 403)
|
||||
&& 'message' in error
|
||||
&& typeof error.message === 'string',
|
||||
)
|
||||
}
|
||||
|
||||
/** Map a thrown value to a response: auth errors keep their status, anything else is a 500. */
|
||||
export function errorResponse(error: unknown): Response {
|
||||
// Same shape as auth.ts authErrorResponse, without loading the Supabase client.
|
||||
if (isAuthError(error)) return jsonResponse(error.status, { error: error.message })
|
||||
return jsonResponse(500, { error: 'internal_error' })
|
||||
}
|
||||
61
server/supabase/functions/_shared/openai-embeddings.ts
Normal file
61
server/supabase/functions/_shared/openai-embeddings.ts
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// server/supabase/functions/_shared/openai-embeddings.ts
|
||||
// Embedding provider port + OpenAI adapter shared by embed-chunks and
|
||||
// search-knowledge. The functions depend on the `EmbeddingProvider` port only,
|
||||
// so the quota/size policy can be tested without the network.
|
||||
|
||||
export const EMBEDDING_MODEL = 'text-embedding-3-small'
|
||||
export const EMBEDDING_DIMENSIONS = 1536
|
||||
export const EMBEDDING_PROVIDER_TIMEOUT_MS = 45_000
|
||||
const OPENAI_EMBEDDINGS_URL = 'https://api.openai.com/v1/embeddings'
|
||||
|
||||
export interface EmbeddingItem {
|
||||
embedding?: unknown
|
||||
index?: unknown
|
||||
}
|
||||
|
||||
export type EmbeddingOutcome =
|
||||
| { ok: true; data: EmbeddingItem[] }
|
||||
/** `upstream`: transport error or non-2xx status. `invalid`: 2xx with an unusable body. */
|
||||
| { ok: false; reason: 'upstream' | 'invalid' }
|
||||
|
||||
export interface EmbeddingProvider {
|
||||
embed(input: string | readonly string[]): Promise<EmbeddingOutcome>
|
||||
}
|
||||
|
||||
export function isEmbedding(value: unknown): value is number[] {
|
||||
return Array.isArray(value)
|
||||
&& value.length === EMBEDDING_DIMENSIONS
|
||||
&& value.every((entry) => typeof entry === 'number' && Number.isFinite(entry))
|
||||
}
|
||||
|
||||
export function createOpenAIEmbeddingProvider(
|
||||
apiKey: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): EmbeddingProvider {
|
||||
return {
|
||||
async embed(input) {
|
||||
let response: Response
|
||||
try {
|
||||
response = await fetchImpl(OPENAI_EMBEDDINGS_URL, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: EMBEDDING_MODEL,
|
||||
input,
|
||||
dimensions: EMBEDDING_DIMENSIONS,
|
||||
}),
|
||||
signal: AbortSignal.timeout(EMBEDDING_PROVIDER_TIMEOUT_MS),
|
||||
})
|
||||
} catch {
|
||||
return { ok: false, reason: 'upstream' }
|
||||
}
|
||||
if (!response.ok) return { ok: false, reason: 'upstream' }
|
||||
const payload = await response.json().catch(() => null) as { data?: unknown } | null
|
||||
if (!payload || !Array.isArray(payload.data)) return { ok: false, reason: 'invalid' }
|
||||
return { ok: true, data: payload.data as EmbeddingItem[] }
|
||||
},
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue