From a59b08fd2cae1ce90d783ce277b366e08290294e Mon Sep 17 00:00:00 2001 From: Dan Lynch Date: Sat, 13 Jun 2026 04:57:44 +0000 Subject: [PATCH] feat(graphile-llm): add onQuotaExceeded option to control embedding failure behavior Add 'degrade' | 'throw' option at the plugin level: - 'degrade' (default): skip vector search silently, fall back to text adapters - 'throw': always error when embedder fails, even if text adapters exist Configurable via createLlmTextSearchPlugin({ onQuotaExceeded: 'throw' }) or GraphileLlmPreset({ onQuotaExceeded: 'throw' }). When no text adapters are available, throws regardless of this setting. --- .../unified-search-embedding.test.ts | 56 ++++++++++++++++++- .../src/plugins/text-search-plugin.ts | 41 +++++++++----- graphile/graphile-llm/src/preset.ts | 14 ++++- graphile/graphile-llm/src/types.ts | 14 +++++ 4 files changed, 108 insertions(+), 17 deletions(-) diff --git a/graphile/graphile-llm/src/__tests__/unified-search-embedding.test.ts b/graphile/graphile-llm/src/__tests__/unified-search-embedding.test.ts index 0ad6f0af3..d0d207045 100644 --- a/graphile/graphile-llm/src/__tests__/unified-search-embedding.test.ts +++ b/graphile/graphile-llm/src/__tests__/unified-search-embedding.test.ts @@ -46,7 +46,7 @@ describe('unifiedSearch embedding integration', () => { await expect( embedTextInWhere(where, nullEmbedder, false) - ).rejects.toThrow('embedding quota exceeded'); + ).rejects.toThrow('embedding failed'); }); it('does not embed empty unifiedSearch strings', async () => { @@ -134,6 +134,60 @@ describe('unifiedSearch embedding integration', () => { }); }); + describe('onQuotaExceeded option', () => { + it('degrade mode (default): leaves unifiedSearch as string when embedder returns null', async () => { + const where = { unifiedSearch: 'some query' }; + await embedTextInWhere(where, nullEmbedder, true, 'degrade'); + + expect(where.unifiedSearch).toBe('some query'); + }); + + it('throw mode: throws on unifiedSearch when embedder returns null even with text adapters', async () => { + const where = { unifiedSearch: 'some query' }; + + await expect( + embedTextInWhere(where, nullEmbedder, true, 'throw') + ).rejects.toThrow('onQuotaExceeded is set to \'throw\''); + }); + + it('throw mode: throws on VectorNearbyInput.text when embedder returns null', async () => { + const where = { vectorEmbedding: { text: 'semantic query' } }; + + await expect( + embedTextInWhere(where, nullEmbedder, true, 'throw') + ).rejects.toThrow('VectorNearbyInput: embedding failed'); + }); + + it('degrade mode: silently removes VectorNearbyInput.text when embedder returns null', async () => { + const where = { vectorEmbedding: { text: 'failed query' } }; + await embedTextInWhere(where, nullEmbedder, true, 'degrade'); + + expect(where.vectorEmbedding).toEqual({}); + }); + + it('throw mode: still works normally when embedder succeeds', async () => { + const where = { unifiedSearch: 'good query' }; + await embedTextInWhere(where, mockEmbedder, true, 'throw'); + + expect(where.unifiedSearch).toEqual({ + __text: 'good query', + __vector: mockVector, + }); + }); + + it('no text adapters: throws regardless of onQuotaExceeded setting', async () => { + const where1 = { unifiedSearch: 'query1' }; + await expect( + embedTextInWhere(where1, nullEmbedder, false, 'degrade') + ).rejects.toThrow('No text search adapters available'); + + const where2 = { unifiedSearch: 'query2' }; + await expect( + embedTextInWhere(where2, nullEmbedder, false, 'throw') + ).rejects.toThrow('No text search adapters available'); + }); + }); + describe('apply function object shape handling', () => { it('plugin.ts apply function handles { __text, __vector } correctly', () => { // Simulate what the apply function does with the transformed value diff --git a/graphile/graphile-llm/src/plugins/text-search-plugin.ts b/graphile/graphile-llm/src/plugins/text-search-plugin.ts index 95472bf26..bad8b1d55 100644 --- a/graphile/graphile-llm/src/plugins/text-search-plugin.ts +++ b/graphile/graphile-llm/src/plugins/text-search-plugin.ts @@ -24,8 +24,10 @@ * (so the schema is stable) but will return a clear error at execution time. * * If the embedder returns null (e.g. quota exceeded when the metering - * plugin is loaded), the text field is silently removed — the query - * continues with text-only search as a graceful fallback. + * plugin is loaded), behavior depends on `onQuotaExceeded`: + * - `'degrade'` (default): silently removes the text field and continues + * with text-only search as a graceful fallback. + * - `'throw'`: always throws an error, even if text adapters could handle it. */ import type { GraphileConfig } from 'graphile-config'; @@ -62,7 +64,8 @@ function hasVectorColumns(pgCodec: any): boolean { export async function embedTextInWhere( obj: any, embedder: (text: string) => Promise, - hasTextAdapters: boolean + hasTextAdapters: boolean, + onQuotaExceeded: 'degrade' | 'throw' = 'degrade' ): Promise { if (!obj || typeof obj !== 'object') return; @@ -79,15 +82,15 @@ export async function embedTextInWhere( const latencyMs = Date.now() - startTime; if (vector === null) { - // Embedder returned null (e.g. quota exceeded) - if (!hasTextAdapters) { - // No text adapters to fall back to — throw error + if (onQuotaExceeded === 'throw' || !hasTextAdapters) { throw new Error( - 'unifiedSearch: embedding quota exceeded and no text search adapters available. ' + - 'Upgrade your plan or add text search columns for graceful fallback.' + 'unifiedSearch: embedding failed (quota exceeded or provider unavailable). ' + + (!hasTextAdapters + ? 'No text search adapters available for fallback. ' + : 'onQuotaExceeded is set to \'throw\'. ') + + 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for text-only fallback.' ); } - // Graceful degradation: leave as plain string, text adapters still work return; } @@ -111,7 +114,12 @@ export async function embedTextInWhere( const latencyMs = Date.now() - startTime; if (vector === null) { - // Embedder returned null (e.g. quota exceeded) — skip vector search + if (onQuotaExceeded === 'throw') { + throw new Error( + 'VectorNearbyInput: embedding failed (quota exceeded or provider unavailable). ' + + 'Upgrade your plan or adjust onQuotaExceeded to \'degrade\' for graceful fallback.' + ); + } delete value.text; return; } @@ -129,11 +137,11 @@ export async function embedTextInWhere( // Recurse into nested filter objects (AND, OR, etc.) if (!Array.isArray(value)) { - pending.push(embedTextInWhere(value, embedder, hasTextAdapters)); + pending.push(embedTextInWhere(value, embedder, hasTextAdapters, onQuotaExceeded)); } else { // Handle arrays (e.g. AND: [...], OR: [...]) for (const item of value) { - pending.push(embedTextInWhere(item, embedder, hasTextAdapters)); + pending.push(embedTextInWhere(item, embedder, hasTextAdapters, onQuotaExceeded)); } } } @@ -150,7 +158,10 @@ export async function embedTextInWhere( * existing `vector` field. When a user provides `text`, the plugin's * resolver wrapper embeds it before passing to pgvector. */ -export function createLlmTextSearchPlugin(): GraphileConfig.Plugin { +export function createLlmTextSearchPlugin( + options: { onQuotaExceeded?: 'degrade' | 'throw' } = {} +): GraphileConfig.Plugin { + const { onQuotaExceeded = 'degrade' } = options; return { name: 'LlmTextSearchPlugin', version: '0.2.0', @@ -243,12 +254,12 @@ export function createLlmTextSearchPlugin(): GraphileConfig.Plugin { async resolve(source: any, args: any, graphqlContext: any, info: any) { // If the query has a `where` argument, check for text/unifiedSearch fields if (args?.where) { - await embedTextInWhere(args.where, embedder, !!hasTextAdapters); + await embedTextInWhere(args.where, embedder, !!hasTextAdapters, onQuotaExceeded); } // Also handle `filter` for relay-style connections if (args?.filter) { - await embedTextInWhere(args.filter, embedder, !!hasTextAdapters); + await embedTextInWhere(args.filter, embedder, !!hasTextAdapters, onQuotaExceeded); } return oldResolve(source, args, graphqlContext, info); diff --git a/graphile/graphile-llm/src/preset.ts b/graphile/graphile-llm/src/preset.ts index d7625667e..793cc859f 100644 --- a/graphile/graphile-llm/src/preset.ts +++ b/graphile/graphile-llm/src/preset.ts @@ -53,6 +53,17 @@ * }) * ``` * + * @example With strict quota enforcement (fail if embedding unavailable): + * ```typescript + * GraphileLlmPreset({ + * defaultEmbedder: { provider: 'openai', model: 'text-embedding-3-small' }, + * metering: true, + * onQuotaExceeded: 'throw', + * // → if billing quota is exceeded, queries with unifiedSearch throw an error + * // → without this, queries silently fall back to text-only search + * }) + * ``` + * * @example With custom entity_id resolution (bill per-database): * ```typescript * GraphileLlmPreset({ @@ -86,6 +97,7 @@ export function GraphileLlmPreset( enableTextSearch = true, enableTextMutations = true, enableRag = false, + onQuotaExceeded, ragDefaults, metering } = options; @@ -102,7 +114,7 @@ export function GraphileLlmPreset( } if (enableTextSearch) { - plugins.push(createLlmTextSearchPlugin()); + plugins.push(createLlmTextSearchPlugin({ onQuotaExceeded })); } if (enableTextMutations) { diff --git a/graphile/graphile-llm/src/types.ts b/graphile/graphile-llm/src/types.ts index cc7836ccb..21fafa0df 100644 --- a/graphile/graphile-llm/src/types.ts +++ b/graphile/graphile-llm/src/types.ts @@ -253,6 +253,20 @@ export interface GraphileLlmOptions { */ enableTextSearch?: boolean; + /** + * Controls behavior when the embedder returns null (e.g. billing quota exceeded). + * + * - `'degrade'` (default): Skip the vector search silently and fall back to + * text-only adapters (tsvector, BM25, trgm). If no text adapters are + * available, throws an error regardless of this setting. + * - `'throw'`: Always throw an error when the embedder fails, even if + * text adapters could handle the query. Use this when semantic search + * is critical and keyword-only results are unacceptable. + * + * @default 'degrade' + */ + onQuotaExceeded?: 'degrade' | 'throw'; + /** * Whether to add `*Text` companion fields on mutation inputs for vector columns. * @default true