diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index b95d4de26c..e073c964d7 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -49,6 +49,9 @@ const { buildAgentScopedContext, buildSkillPrimeContentParts, buildInitialToolSessions, + hasUrlContextTool, + appendYouTubeVideoParts, + resolveYouTubeInjectionConfig, } = require('@librechat/api'); const { Callback, @@ -459,6 +462,39 @@ class AgentClient extends BaseClient { return formattedMessage; }); + /** + * Native YouTube -> video understanding: when Google `url_context` is enabled + * (resolved to the native `urlContext` provider tool), inject any YouTube URLs + * from the latest user turn as Gemini `fileData` video parts. The URL Context + * tool cannot read YouTube, so this routes those links through the video path + * while other URLs still flow through `urlContext`. Done after token counting + * (video tokens are reported by the provider) and only on the LLM payload, so + * the memory copy and persisted message are untouched. + */ + const latestOrdered = orderedMessages[orderedMessages.length - 1]; + const provider = this.options.agent?.provider; + if ( + latestOrdered?.isCreatedByUser === true && + (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) && + hasUrlContextTool(this.options.agent?.tools) + ) { + const latestFormatted = formattedMessages[formattedMessages.length - 1]; + /** Use the resolved run model (model_parameters override) rather than the saved base model. */ + const resolvedModel = + this.options.agent?.model_parameters?.model ?? this.options.agent?.model; + const { max, mimeType } = resolveYouTubeInjectionConfig({ + provider, + model: resolvedModel, + }); + latestFormatted.content = appendYouTubeVideoParts({ + enabled: true, + text: latestOrdered.text, + content: latestFormatted.content, + max, + mimeType, + }); + } + payload = formattedMessages; this.memoryPayload = hasFileContext ? memoryPayload : null; messages = orderedMessages; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 84e8dcc38d..81df389135 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -289,6 +289,7 @@ "com_endpoint_google_topk": "Top-k changes how the model selects tokens for output. A top-k of 1 means the selected token is the most probable among all tokens in the model's vocabulary (also called greedy decoding), while a top-k of 3 means that the next token is selected from among the 3 most probable tokens (using temperature).", "com_endpoint_google_topp": "Top-p changes how the model selects tokens for output. Tokens are selected from most K (see topK parameter) probable to least until the sum of their probabilities equals the top-p value.", "com_endpoint_google_use_search_grounding": "Use Google's search grounding feature to enhance responses with real-time web search results. This enables models to access current information and provide more accurate, up-to-date answers.", + "com_endpoint_google_use_url_context": "Let the model read URLs you include in your message using Google's URL Context tool (web pages, images, and PDFs). YouTube links are handled separately via native video understanding, since the URL Context tool does not support YouTube.", "com_endpoint_instructions_assistants": "Override Instructions", "com_endpoint_instructions_assistants_placeholder": "Overrides the instructions of the assistant. This is useful for modifying the behavior on a per-run basis.", "com_endpoint_max_output_tokens": "Max Output Tokens", @@ -362,6 +363,7 @@ "com_endpoint_use_active_assistant": "Use Active Assistant", "com_endpoint_use_responses_api": "Use Responses API", "com_endpoint_use_search_grounding": "Grounding with Google Search", + "com_endpoint_use_url_context": "URL Context", "com_endpoint_verbosity": "Verbosity", "com_error_endpoint_models_not_loaded": "Models for {{0}} could not be loaded. Please refresh the page and try again.", "com_error_expired_user_key": "Provided key for {{0}} expired at {{1}}. Please provide a new key and try again.", diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 09917ffef3..61e1a8afb2 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -244,6 +244,13 @@ function countWebSearchDefinitions(toolDefinitions: Array<{ name: string }> | un ); } +function countUrlContextTools(tools: unknown[] | undefined): number { + return ( + tools?.filter((tool) => tool != null && typeof tool === 'object' && 'urlContext' in tool) + .length ?? 0 + ); +} + describe('initializeAgent — custom provider token lookup', () => { const CUSTOM_PROVIDER = 'EduGPT'; @@ -635,6 +642,62 @@ describe('initializeAgent — provider web_search precedence', () => { expect(countNamedWebSearchTools(result.tools)).toBe(1); expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); }); + + it('treats the Google urlContext tool as a native provider tool', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.GOOGLE, + model: 'gemini-1.5-flash', + providerTools: [{ urlContext: {} }], + }); + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.GOOGLE]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([{ urlContext: {} }]); + expect(countUrlContextTools(result.tools)).toBe(1); + }); + + it('preserves the Google urlContext tool when LibreChat web_search is also enabled', async () => { + /** + * url_context is unrelated to web search, so the LibreChat web_search conflict + * resolver must not strip the native urlContext tool. The combination of a + * provider tool with an agent tool requires a combination-capable Gemini model. + */ + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.GOOGLE, + model: 'gemini-3.5-flash', + providerTools: [{ urlContext: {} }], + loadedToolDefinitions: [libreChatWebSearchDefinition], + }); + agent.tools = [Tools.web_search]; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.GOOGLE]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([{ urlContext: {} }]); + expect(countUrlContextTools(result.tools)).toBe(1); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); + }); }); describe('initializeAgent — stable and dynamic instruction fields', () => { diff --git a/packages/api/src/endpoints/google/index.ts b/packages/api/src/endpoints/google/index.ts index 46ec106869..339fada2ef 100644 --- a/packages/api/src/endpoints/google/index.ts +++ b/packages/api/src/endpoints/google/index.ts @@ -1,2 +1,3 @@ export * from './llm'; export * from './initialize'; +export * from './youtube'; diff --git a/packages/api/src/endpoints/google/llm.spec.ts b/packages/api/src/endpoints/google/llm.spec.ts index 66ef6f84e7..483d08f665 100644 --- a/packages/api/src/endpoints/google/llm.spec.ts +++ b/packages/api/src/endpoints/google/llm.spec.ts @@ -1179,6 +1179,181 @@ describe('getGoogleConfig', () => { }); }); + describe('URL Context Functionality', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + it('should enable the urlContext tool when url_context is true', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + url_context: true, + }, + }); + + expect(result.tools).toContainEqual({ urlContext: {} }); + }); + + it('should not include the urlContext tool when url_context is false', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + url_context: false, + }, + }); + + expect(result.tools).not.toContainEqual({ urlContext: {} }); + }); + + it('should not include the urlContext tool when url_context is unset', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + }, + }); + + expect(result.tools).not.toContainEqual({ urlContext: {} }); + }); + + it('should enable url context via defaultParams', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + }, + defaultParams: { + url_context: true, + }, + }); + + expect(result.tools).toContainEqual({ urlContext: {} }); + }); + + it('should enable url context via addParams', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + }, + addParams: { + url_context: true, + }, + }); + + expect(result.tools).toContainEqual({ urlContext: {} }); + }); + + it('should let addParams override a defaultParams url_context', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + }, + defaultParams: { + url_context: true, + }, + addParams: { + url_context: false, + }, + }); + + expect(result.tools).not.toContainEqual({ urlContext: {} }); + }); + + it('should disable url context via dropParams', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + url_context: true, + }, + dropParams: ['url_context'], + }); + + expect(result.tools).not.toContainEqual({ urlContext: {} }); + }); + + it('should not leak url_context into the llmConfig', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + url_context: true, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('url_context'); + }); + + it('should enable both googleSearch and urlContext tools together', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-2.5-flash', + web_search: true, + url_context: true, + }, + }); + + expect(result.tools).toContainEqual({ googleSearch: {} }); + expect(result.tools).toContainEqual({ urlContext: {} }); + }); + + it('should not include the urlContext tool on models that do not support it (Gemini < 2.5)', () => { + for (const model of ['gemini-2.0-flash', 'gemini-1.5-pro']) { + const result = getGoogleConfig(credentials, { + modelOptions: { + model, + url_context: true, + }, + }); + expect(result.tools).not.toContainEqual({ urlContext: {} }); + } + }); + + it('should enable the urlContext tool on Gemini 3.x models', () => { + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3-pro-preview', + url_context: true, + }, + }); + + expect(result.tools).toContainEqual({ urlContext: {} }); + }); + + it('should not include the urlContext tool on non-text modality variants', () => { + for (const model of [ + 'gemini-2.5-flash-image', + 'gemini-3-pro-image-preview', + 'gemini-3.5-flash-live', + 'gemini-2.5-flash-tts', + 'gemini-2.5-flash-preview-native-audio-dialog', + ]) { + const result = getGoogleConfig(credentials, { + modelOptions: { + model, + url_context: true, + }, + }); + expect(result.tools).not.toContainEqual({ urlContext: {} }); + } + }); + + it('should enable the urlContext tool for the Vertex AI provider', () => { + const vertexCredentials = { + [AuthKeys.GOOGLE_SERVICE_KEY]: { + project_id: 'test-project', + }, + }; + + const result = getGoogleConfig(vertexCredentials, { + modelOptions: { + model: 'gemini-2.5-flash', + url_context: true, + }, + }); + + expect(result.provider).toBe(Providers.VERTEXAI); + expect(result.tools).toContainEqual({ urlContext: {} }); + }); + }); + describe('Default and Add Parameters', () => { it('should apply default parameters when fields are undefined', () => { const credentials = { diff --git a/packages/api/src/endpoints/google/llm.ts b/packages/api/src/endpoints/google/llm.ts index b0d3f8cea5..d6ce10ce56 100644 --- a/packages/api/src/endpoints/google/llm.ts +++ b/packages/api/src/endpoints/google/llm.ts @@ -1,4 +1,5 @@ import { Providers } from '@librechat/agents'; +import { logger } from '@librechat/data-schemas'; import { googleSettings, AuthKeys, removeNullishValues } from 'librechat-data-provider'; import type { GoogleClientOptions, VertexAIClientOptions } from '@librechat/agents'; import type { GoogleAIToolType } from '@librechat/agents/langchain/google-common'; @@ -135,6 +136,30 @@ function isGemini35Flash(model: string) { return modelId === GEMINI_3_5_FLASH || modelId.startsWith(`${GEMINI_3_5_FLASH}-`); } +const urlContextModelRegex = /gemini-(\d+)(?:\.(\d+))?/i; +const urlContextExcludedModalityRegex = /(?:^|-)(?:image|live|tts|audio)(?:-|$)/; + +/** + * The native URL Context tool is supported only on text Gemini 2.5+ and 3.x models + * (https://ai.google.dev/gemini-api/docs/url-context#supported_models). Modality-specific + * variants (image, live, TTS) do not accept it, mirroring the Google tool-combination exclusion. + * Enabling it on an unsupported model returns a provider-side error, so we skip the tool there. + */ +function supportsUrlContext(model: string): boolean { + const normalized = model.trim().toLowerCase(); + const modelId = normalized.split('/').pop() ?? normalized; + if (urlContextExcludedModalityRegex.test(modelId)) { + return false; + } + const match = urlContextModelRegex.exec(modelId); + if (!match) { + return false; + } + const major = Number(match[1]); + const minor = Number(match[2] ?? '0'); + return major > 2 || (major === 2 && minor >= 5); +} + function getVertexMultiRegionEndpoint(location: string): string | undefined { return vertexMultiRegionEndpoints.get(location); } @@ -361,6 +386,7 @@ export function getGoogleConfig( const { web_search, + url_context, thinkingLevel, thinking = googleSettings.thinking.default, thinkingBudget = googleSettings.thinkingBudget.default, @@ -368,6 +394,7 @@ export function getGoogleConfig( } = sanitizeModelOptions(options.modelOptions); let enableWebSearch = web_search; + let enableUrlContext = url_context; const llmConfig = removeNullishValues( { @@ -514,6 +541,14 @@ export function getGoogleConfig( continue; } + /** Handle url_context separately - resolved to a native tool, not config */ + if (key === 'url_context') { + if (enableUrlContext === undefined && typeof value === 'boolean') { + enableUrlContext = value; + } + continue; + } + if (knownGoogleParams.has(key)) { /** Route known Google params to llmConfig only if undefined */ applyDefaultParams(llmConfig as Record, { [key]: value }); @@ -536,6 +571,14 @@ export function getGoogleConfig( continue; } + /** Handle url_context separately - resolved to a native tool, not config */ + if (key === 'url_context') { + if (typeof value === 'boolean') { + enableUrlContext = value; + } + continue; + } + if (knownGoogleParams.has(key)) { /** Route known Google params to llmConfig */ (llmConfig as Record)[key] = value; @@ -555,6 +598,11 @@ export function getGoogleConfig( return; } + if (param === 'url_context') { + enableUrlContext = false; + return; + } + if (param === 'endpoint') { shouldSyncVertexEndpoint = false; hasCustomVertexEndpoint = false; @@ -603,6 +651,17 @@ export function getGoogleConfig( tools.push({ googleSearch: {} }); } + if (enableUrlContext) { + const urlContextModel = ((llmConfig as { model?: string }).model || modelName) ?? ''; + if (supportsUrlContext(urlContextModel)) { + tools.push({ urlContext: {} }); + } else { + logger.debug( + `[getGoogleConfig] url_context enabled but model "${urlContextModel}" does not support the URL Context tool (Gemini 2.5+ only); skipping.`, + ); + } + } + // Return the final shape return { /** @type {GoogleAIToolType[]} */ diff --git a/packages/api/src/endpoints/google/youtube.spec.ts b/packages/api/src/endpoints/google/youtube.spec.ts new file mode 100644 index 0000000000..5a4c42a9c8 --- /dev/null +++ b/packages/api/src/endpoints/google/youtube.spec.ts @@ -0,0 +1,365 @@ +import { Providers } from '@librechat/agents'; +import { ContentTypes } from 'librechat-data-provider'; +import type { MessageContentComplex } from '@librechat/agents'; +import { + hasUrlContextTool, + extractYouTubeUrls, + appendYouTubeVideoParts, + DEFAULT_MAX_YOUTUBE_PARTS, + resolveYouTubeInjectionConfig, +} from './youtube'; + +const WATCH = (id: string) => `https://www.youtube.com/watch?v=${id}`; + +describe('extractYouTubeUrls', () => { + it('extracts a standard watch URL', () => { + expect(extractYouTubeUrls('Watch https://www.youtube.com/watch?v=dQw4w9WgXcQ now')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + }); + + it('extracts youtu.be short links (with tracking params)', () => { + expect(extractYouTubeUrls('see https://youtu.be/dQw4w9WgXcQ?si=abc123')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + }); + + it('extracts shorts URLs', () => { + expect(extractYouTubeUrls('https://www.youtube.com/shorts/dQw4w9WgXcQ')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + }); + + it('extracts live URLs', () => { + expect(extractYouTubeUrls('https://www.youtube.com/live/dQw4w9WgXcQ')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + }); + + it('extracts embed URLs', () => { + expect(extractYouTubeUrls('https://www.youtube.com/embed/dQw4w9WgXcQ')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + }); + + it('handles watch URLs with leading/trailing query params', () => { + expect( + extractYouTubeUrls('https://www.youtube.com/watch?list=PLxyz&v=dQw4w9WgXcQ&t=42'), + ).toEqual([WATCH('dQw4w9WgXcQ')]); + }); + + it('handles m. and scheme-less hosts', () => { + expect(extractYouTubeUrls('m.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual([WATCH('dQw4w9WgXcQ')]); + }); + + it('handles other YouTube subdomains (music., gaming.)', () => { + expect(extractYouTubeUrls('https://music.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + expect(extractYouTubeUrls('https://gaming.youtube.com/watch?v=dQw4w9WgXcQ')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + }); + + it('handles youtube-nocookie embed URLs', () => { + expect(extractYouTubeUrls('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ')).toEqual([ + WATCH('dQw4w9WgXcQ'), + ]); + }); + + it('preserves order across multiple distinct videos', () => { + const text = 'https://youtu.be/aaaaaaaaaaa then https://www.youtube.com/watch?v=bbbbbbbbbbb'; + expect(extractYouTubeUrls(text)).toEqual([WATCH('aaaaaaaaaaa'), WATCH('bbbbbbbbbbb')]); + }); + + it('de-duplicates the same video id across different URL forms', () => { + const text = + 'https://youtu.be/dQw4w9WgXcQ and https://www.youtube.com/watch?v=dQw4w9WgXcQ&t=10'; + expect(extractYouTubeUrls(text)).toEqual([WATCH('dQw4w9WgXcQ')]); + }); + + it('ignores non-YouTube URLs', () => { + expect(extractYouTubeUrls('https://example.com/watch?v=dQw4w9WgXcQ')).toEqual([]); + }); + + it('ignores look-alike hosts (e.g. notyoutube.com, evil-youtube.com)', () => { + expect(extractYouTubeUrls('https://notyoutube.com/watch?v=dQw4w9WgXcQ')).toEqual([]); + expect(extractYouTubeUrls('https://evil-youtube.com/watch?v=dQw4w9WgXcQ')).toEqual([]); + expect(extractYouTubeUrls('https://youtube.com.evil.com/watch?v=dQw4w9WgXcQ')).toEqual([]); + }); + + it('ignores ids that are not exactly 11 valid characters', () => { + expect(extractYouTubeUrls('https://www.youtube.com/watch?v=tooShort')).toEqual([]); + }); + + it('returns an empty array for empty / nullish input', () => { + expect(extractYouTubeUrls('')).toEqual([]); + expect(extractYouTubeUrls(undefined)).toEqual([]); + expect(extractYouTubeUrls(null)).toEqual([]); + }); + + it('caps the number of returned URLs at the default', () => { + const ids = Array.from( + { length: DEFAULT_MAX_YOUTUBE_PARTS + 3 }, + (_, i) => `vid${String(i).padStart(8, '0')}`, + ); + const text = ids.map((id) => `https://youtu.be/${id}`).join(' '); + expect(extractYouTubeUrls(text)).toHaveLength(DEFAULT_MAX_YOUTUBE_PARTS); + }); + + it('honors a custom max', () => { + const text = 'https://youtu.be/aaaaaaaaaaa https://youtu.be/bbbbbbbbbbb'; + expect(extractYouTubeUrls(text, 1)).toEqual([WATCH('aaaaaaaaaaa')]); + }); + + it('returns an empty array when max is zero', () => { + expect(extractYouTubeUrls('https://youtu.be/dQw4w9WgXcQ', 0)).toEqual([]); + }); +}); + +describe('hasUrlContextTool', () => { + it('returns true when a urlContext tool is present', () => { + expect(hasUrlContextTool([{ urlContext: {} }])).toBe(true); + }); + + it('returns true when mixed with other provider tools', () => { + expect(hasUrlContextTool([{ googleSearch: {} }, { urlContext: {} }])).toBe(true); + }); + + it('returns false when only other provider tools are present', () => { + expect(hasUrlContextTool([{ googleSearch: {} }])).toBe(false); + }); + + it('returns false for empty / non-array inputs', () => { + expect(hasUrlContextTool([])).toBe(false); + expect(hasUrlContextTool(undefined)).toBe(false); + expect(hasUrlContextTool(null)).toBe(false); + expect(hasUrlContextTool('urlContext')).toBe(false); + }); +}); + +describe('appendYouTubeVideoParts', () => { + const youtubeText = 'Summarize https://www.youtube.com/watch?v=dQw4w9WgXcQ for me'; + + it('returns content unchanged when disabled', () => { + const result = appendYouTubeVideoParts({ + enabled: false, + text: youtubeText, + content: youtubeText, + }); + expect(result).toBe(youtubeText); + }); + + it('returns content unchanged when there are no YouTube URLs', () => { + const text = 'Read https://example.com/article'; + const result = appendYouTubeVideoParts({ enabled: true, text, content: text }); + expect(result).toBe(text); + }); + + it('upgrades string content to a parts array, stripping the URL from text', () => { + const result = appendYouTubeVideoParts({ + enabled: true, + text: youtubeText, + content: youtubeText, + }); + + expect(Array.isArray(result)).toBe(true); + const parts = result as MessageContentComplex[]; + /** The YouTube URL is removed from the text since it now flows through video understanding. */ + expect(parts[0]).toEqual({ type: ContentTypes.TEXT, text: 'Summarize for me' }); + + const mediaPart = parts[1] as Record; + expect(mediaPart.type).toBe('media'); + expect(mediaPart.fileUri).toBe(WATCH('dQw4w9WgXcQ')); + /** The agents media converter requires the `mimeType` key to be present. */ + expect('mimeType' in mediaPart).toBe(true); + expect(mediaPart.mimeType).toBeUndefined(); + }); + + it('strips the YouTube URL from an existing text part but leaves other parts intact', () => { + const content: MessageContentComplex[] = [ + { type: ContentTypes.TEXT, text: youtubeText } as MessageContentComplex, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,AAAA' }, + } as MessageContentComplex, + ]; + const result = appendYouTubeVideoParts({ + enabled: true, + text: youtubeText, + content, + }) as MessageContentComplex[]; + + expect(result).toHaveLength(3); + expect(result[0]).toEqual({ type: ContentTypes.TEXT, text: 'Summarize for me' }); + expect(result[1]).toEqual(content[1]); + expect((result[2] as Record).fileUri).toBe(WATCH('dQw4w9WgXcQ')); + }); + + it('strips YouTube links from text but leaves non-YouTube URLs intact for urlContext', () => { + const text = 'Watch https://youtu.be/dQw4w9WgXcQ and read https://example.com/article please'; + const result = appendYouTubeVideoParts({ enabled: true, text, content: text }); + + const parts = result as MessageContentComplex[]; + const textPart = parts[0] as { type: string; text: string }; + expect(textPart.text).not.toContain('youtu.be'); + expect(textPart.text).not.toContain('dQw4w9WgXcQ'); + expect(textPart.text).toContain('https://example.com/article'); + expect(textPart.text).toBe('Watch and read https://example.com/article please'); + + const mediaParts = parts.filter((p) => (p as Record).type === 'media'); + expect(mediaParts).toHaveLength(1); + expect((mediaParts[0] as Record).fileUri).toBe(WATCH('dQw4w9WgXcQ')); + }); + + it('drops a text part that becomes empty after stripping a sole YouTube URL', () => { + const text = 'https://youtu.be/dQw4w9WgXcQ'; + const result = appendYouTubeVideoParts({ enabled: true, text, content: text }); + + const parts = result as MessageContentComplex[]; + expect(parts).toHaveLength(1); + expect((parts[0] as Record).type).toBe('media'); + expect((parts[0] as Record).fileUri).toBe(WATCH('dQw4w9WgXcQ')); + }); + + it('keeps over-limit YouTube links in the text (only injected ones are stripped)', () => { + const text = 'https://youtu.be/aaaaaaaaaaa and https://youtu.be/bbbbbbbbbbb'; + const result = appendYouTubeVideoParts({ + enabled: true, + text, + content: text, + max: 1, + }) as MessageContentComplex[]; + + const mediaParts = result.filter((p) => (p as Record).type === 'media'); + expect(mediaParts).toHaveLength(1); + expect((mediaParts[0] as Record).fileUri).toBe(WATCH('aaaaaaaaaaa')); + + const textPart = result.find((p) => (p as Record).type === 'text') as { + text: string; + }; + /** The injected link is stripped; the over-limit link is preserved for the model. */ + expect(textPart.text).not.toContain('aaaaaaaaaaa'); + expect(textPart.text).toContain('bbbbbbbbbbb'); + }); + + it('keeps a timestamped YouTube link in the text so the moment cue survives', () => { + const text = 'what happens here https://youtu.be/dQw4w9WgXcQ?t=90'; + const result = appendYouTubeVideoParts({ enabled: true, text, content: text }); + + const parts = result as MessageContentComplex[]; + const textPart = parts.find((p) => (p as Record).type === 'text') as { + text: string; + }; + expect(textPart.text).toContain('t=90'); + + const mediaParts = parts.filter((p) => (p as Record).type === 'media'); + expect(mediaParts).toHaveLength(1); + expect((mediaParts[0] as Record).fileUri).toBe(WATCH('dQw4w9WgXcQ')); + }); + + it('keeps a timestamp that precedes the video id in the watch URL', () => { + const text = 'jump to https://www.youtube.com/watch?t=90&v=dQw4w9WgXcQ'; + const result = appendYouTubeVideoParts({ enabled: true, text, content: text }); + + const parts = result as MessageContentComplex[]; + const textPart = parts.find((p) => (p as Record).type === 'text') as { + text: string; + }; + expect(textPart.text).toContain('t=90'); + expect(textPart.text).toContain('dQw4w9WgXcQ'); + + const mediaParts = parts.filter((p) => (p as Record).type === 'media'); + expect(mediaParts).toHaveLength(1); + }); + + it('does not duplicate a video already present as a media part', () => { + const content: MessageContentComplex[] = [ + { type: ContentTypes.TEXT, text: youtubeText } as MessageContentComplex, + { + type: 'media', + mimeType: undefined, + fileUri: WATCH('dQw4w9WgXcQ'), + } as MessageContentComplex, + ]; + const result = appendYouTubeVideoParts({ enabled: true, text: youtubeText, content }); + expect(result).toBe(content); + }); + + it('de-duplicates against an existing fileData part', () => { + const content: MessageContentComplex[] = [ + { type: ContentTypes.TEXT, text: youtubeText } as MessageContentComplex, + { + type: 'media', + fileData: { fileUri: WATCH('dQw4w9WgXcQ') }, + } as unknown as MessageContentComplex, + ]; + const result = appendYouTubeVideoParts({ enabled: true, text: youtubeText, content }); + expect(result).toBe(content); + }); + + it('stamps the provided mimeType on the media part (Vertex)', () => { + const result = appendYouTubeVideoParts({ + enabled: true, + text: youtubeText, + content: youtubeText, + mimeType: 'video/mp4', + }) as MessageContentComplex[]; + + const mediaPart = result[result.length - 1] as Record; + expect(mediaPart.type).toBe('media'); + expect(mediaPart.mimeType).toBe('video/mp4'); + }); + + it('respects max=1 even when the message has multiple YouTube links', () => { + const text = 'https://youtu.be/aaaaaaaaaaa and https://youtu.be/bbbbbbbbbbb'; + const result = appendYouTubeVideoParts({ + enabled: true, + text, + content: text, + max: 1, + }) as MessageContentComplex[]; + + const mediaParts = result.filter((p) => (p as Record).type === 'media'); + expect(mediaParts).toHaveLength(1); + expect((mediaParts[0] as Record).fileUri).toBe(WATCH('aaaaaaaaaaa')); + }); +}); + +describe('resolveYouTubeInjectionConfig', () => { + it('caps Vertex at one video and sets a video/mp4 mimeType', () => { + expect( + resolveYouTubeInjectionConfig({ provider: Providers.VERTEXAI, model: 'gemini-2.5-flash' }), + ).toEqual({ + max: 1, + mimeType: 'video/mp4', + }); + }); + + it('allows multiple videos with no mimeType for Gemini 2.5+ on the Developer API', () => { + expect( + resolveYouTubeInjectionConfig({ provider: Providers.GOOGLE, model: 'gemini-2.5-flash' }), + ).toEqual({ + max: DEFAULT_MAX_YOUTUBE_PARTS, + }); + expect( + resolveYouTubeInjectionConfig({ provider: Providers.GOOGLE, model: 'gemini-3-pro-preview' }), + ).toEqual({ + max: DEFAULT_MAX_YOUTUBE_PARTS, + }); + }); + + it('caps pre-2.5 and unknown Developer API models at one video', () => { + expect( + resolveYouTubeInjectionConfig({ provider: Providers.GOOGLE, model: 'gemini-2.0-flash' }), + ).toEqual({ + max: 1, + }); + expect( + resolveYouTubeInjectionConfig({ provider: Providers.GOOGLE, model: 'gemini-1.5-pro' }), + ).toEqual({ + max: 1, + }); + expect(resolveYouTubeInjectionConfig({ provider: Providers.GOOGLE })).toEqual({ max: 1 }); + }); +}); diff --git a/packages/api/src/endpoints/google/youtube.ts b/packages/api/src/endpoints/google/youtube.ts new file mode 100644 index 0000000000..58f3a08fe8 --- /dev/null +++ b/packages/api/src/endpoints/google/youtube.ts @@ -0,0 +1,250 @@ +import { Providers } from '@librechat/agents'; +import { ContentTypes } from 'librechat-data-provider'; +import type { MessageContentComplex } from '@librechat/agents'; + +/** + * Native YouTube -> Gemini video-understanding support for the Google `url_context` param. + * + * The Gemini URL Context tool does NOT support YouTube as a content type; YouTube must be + * passed as a video part (`fileData.fileUri`) via the video-understanding path. When + * `url_context` is enabled we detect YouTube URLs in the user's message and inject them as + * `{ type: 'media', fileUri }` content blocks, which the `@librechat/agents` Google content + * formatter converts into `fileData` parts. All other URLs keep flowing through the native + * `urlContext` tool. + */ + +/** Per-message cap on auto-injected YouTube video parts for Gemini 2.5+ (the API allows up to 10). */ +export const DEFAULT_MAX_YOUTUBE_PARTS = 5; + +/** A Gemini video-understanding content block (becomes a `fileData` part downstream). */ +export interface YouTubeVideoPart { + type: 'media'; + /** + * Always present as a key so the agents `messageContentMedia` `'mimeType' in content` guard + * passes. The Gemini Developer API omits it for YouTube (value `undefined`, dropped on JSON + * serialization); Vertex samples set `video/mp4`. + */ + mimeType?: string; + fileUri: string; +} + +const GEMINI_VERSION_REGEX = /gemini-(\d+)(?:\.(\d+))?/i; + +/** Gemini 2.5+ models accept up to 10 YouTube videos per request; earlier models accept 1. */ +function isGemini25OrLater(model?: string): boolean { + if (typeof model !== 'string') { + return false; + } + const match = GEMINI_VERSION_REGEX.exec(model); + if (!match) { + return false; + } + const major = Number(match[1]); + const minor = Number(match[2] ?? '0'); + return major > 2 || (major === 2 && minor >= 5); +} + +/** + * Resolves provider/model-aware YouTube injection limits. + * - Vertex: every Vertex sample sets a `video/mp4` mimeType on YouTube fileData, and Vertex is + * capped conservatively at a single YouTube URL per request. + * - Gemini Developer API: omits the mimeType; 2.5+ models accept multiple videos, earlier ones one. + */ +export function resolveYouTubeInjectionConfig(params: { provider?: string; model?: string }): { + max: number; + mimeType?: string; +} { + if (params.provider === Providers.VERTEXAI) { + return { max: 1, mimeType: 'video/mp4' }; + } + return { max: isGemini25OrLater(params.model) ? DEFAULT_MAX_YOUTUBE_PARTS : 1 }; +} + +/** + * Host + path alternation shared by the detection and strip regexes. Accepts any YouTube + * subdomain (`www.`, `m.`, `music.`, ...) for youtube.com plus youtu.be and youtube-nocookie + * embed links. The capture group is always the 11-char video id. + */ +const YOUTUBE_HOST_PATH = + '(?:(?:[a-z0-9-]+\\.)*youtube\\.com\\/(?:watch\\?(?:\\S*?&)?v=|shorts\\/|live\\/|embed\\/|v\\/)' + + '|(?:www\\.)?youtube-nocookie\\.com\\/embed\\/' + + '|youtu\\.be\\/)'; + +/** + * Matches YouTube watch/share/shorts/live/embed (incl. youtube-nocookie) URLs and captures the + * 11-char video id. The leading lookbehind rejects hosts embedded in a longer domain (e.g. + * `notyoutube.com`, `evil-youtube.com`); the trailing lookahead rejects ids inside a longer token. + */ +const YOUTUBE_URL_REGEX = new RegExp( + `(?): string { + const replaced = text.replace(YOUTUBE_URL_STRIP_REGEX, (match, videoId) => { + if (!injectedIds.has(videoId)) { + return match; + } + /** Test the whole URL: the timestamp can precede `v=` (e.g. `watch?t=90&v=`). */ + if (YOUTUBE_TIMESTAMP_REGEX.test(match)) { + return match; + } + return ''; + }); + if (replaced === text) { + return text; + } + return replaced + .replace(/[ \t]{2,}/g, ' ') + .replace(/[ \t]+$/gm, '') + .trim(); +} + +function resolveLimit(max?: number): number { + if (max == null || !Number.isFinite(max)) { + return DEFAULT_MAX_YOUTUBE_PARTS; + } + return Math.max(0, Math.floor(max)); +} + +/** + * Extracts YouTube URLs from text, normalized to canonical `watch?v=` form, de-duplicated + * by video id (first occurrence wins), and capped at `max` (default {@link DEFAULT_MAX_YOUTUBE_PARTS}). + */ +export function extractYouTubeUrls(text?: string | null, max?: number): string[] { + const limit = resolveLimit(max); + if (limit === 0 || typeof text !== 'string' || text.length === 0) { + return []; + } + + const seen = new Set(); + const urls: string[] = []; + YOUTUBE_URL_REGEX.lastIndex = 0; + let match: RegExpExecArray | null; + while ((match = YOUTUBE_URL_REGEX.exec(text)) !== null) { + const videoId = match[1]; + if (seen.has(videoId)) { + continue; + } + seen.add(videoId); + urls.push(`https://www.youtube.com/watch?v=${videoId}`); + if (urls.length >= limit) { + break; + } + } + return urls; +} + +/** True when the resolved provider tool list contains the native Google `urlContext` tool. */ +export function hasUrlContextTool(tools: unknown): boolean { + if (!Array.isArray(tools)) { + return false; + } + return tools.some((tool) => tool != null && typeof tool === 'object' && 'urlContext' in tool); +} + +function toBaseParts(content: string | MessageContentComplex[]): MessageContentComplex[] { + if (Array.isArray(content)) { + return content; + } + if (typeof content === 'string' && content.length > 0) { + return [{ type: ContentTypes.TEXT, text: content } as MessageContentComplex]; + } + return []; +} + +function stripYouTubeFromTextParts( + parts: MessageContentComplex[], + injectedIds: Set, +): MessageContentComplex[] { + const result: MessageContentComplex[] = []; + for (const part of parts) { + const record = part as Record; + if (record != null && record.type === ContentTypes.TEXT && typeof record.text === 'string') { + const stripped = stripYouTubeUrls(record.text, injectedIds); + if (stripped.length > 0) { + result.push({ ...record, text: stripped } as MessageContentComplex); + } + continue; + } + result.push(part); + } + return result; +} + +function collectFileUris(parts: MessageContentComplex[]): Set { + const uris = new Set(); + for (const part of parts) { + if (part == null || typeof part !== 'object') { + continue; + } + const record = part as Record; + if (typeof record.fileUri === 'string') { + uris.add(record.fileUri); + } + const fileData = record.fileData as { fileUri?: unknown } | undefined; + if (fileData != null && typeof fileData.fileUri === 'string') { + uris.add(fileData.fileUri); + } + } + return uris; +} + +/** + * Appends YouTube video parts to a message's content when `url_context` is enabled, and removes + * the matched YouTube URLs from the prompt text so the `urlContext` tool (which reads URLs from + * the text and cannot fetch YouTube) does not spend its URL budget on them. Non-YouTube URLs are + * left intact for the tool. No-ops when disabled, when no YouTube URLs are found, or when every + * URL is already present as a media part. A string content is upgraded to a parts array only when + * something is added. + */ +export function appendYouTubeVideoParts(params: { + enabled: boolean; + text?: string | null; + content: string | MessageContentComplex[]; + max?: number; + mimeType?: string; +}): string | MessageContentComplex[] { + const { enabled, text, content, max, mimeType } = params; + if (!enabled) { + return content; + } + + const urls = extractYouTubeUrls(text, max); + if (urls.length === 0) { + return content; + } + + const baseParts = toBaseParts(content); + const existing = collectFileUris(baseParts); + const newParts = urls + .filter((url) => !existing.has(url)) + .map((fileUri) => ({ type: 'media', mimeType, fileUri }) as MessageContentComplex); + + if (newParts.length === 0) { + return content; + } + + /** Only strip the links actually routed to video (capped + deduped set); over-limit links stay. */ + const injectedIds = new Set( + urls.map((url) => new URL(url).searchParams.get('v')).filter((id): id is string => id != null), + ); + return [...stripYouTubeFromTextParts(baseParts, injectedIds), ...newParts]; +} diff --git a/packages/api/src/endpoints/openai/config.google.spec.ts b/packages/api/src/endpoints/openai/config.google.spec.ts index 8533672277..20e283510a 100644 --- a/packages/api/src/endpoints/openai/config.google.spec.ts +++ b/packages/api/src/endpoints/openai/config.google.spec.ts @@ -213,6 +213,120 @@ describe('getOpenAIConfig - Google Compatibility', () => { }); }); + describe('URL Context Support via addParams', () => { + const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' }); + const endpoint = 'Gemini (Custom)'; + const reverseProxyUrl = 'https://generativelanguage.googleapis.com/v1beta/openai'; + + it('should enable urlContext tool when url_context: true in addParams', () => { + const result = getOpenAIConfig( + apiKey, + { + modelOptions: { model: 'gemini-2.5-flash' }, + customParams: { defaultParamsEndpoint: 'google' }, + addParams: { url_context: true }, + reverseProxyUrl, + }, + endpoint, + ); + + expect(result.tools).toEqual([{ urlContext: {} }]); + }); + + it('should disable urlContext tool when url_context: false in addParams', () => { + const result = getOpenAIConfig( + apiKey, + { + modelOptions: { model: 'gemini-2.5-flash', url_context: true }, + customParams: { defaultParamsEndpoint: 'google' }, + addParams: { url_context: false }, + reverseProxyUrl, + }, + endpoint, + ); + + expect(result.tools).toEqual([]); + }); + + it('should disable urlContext when in dropParams', () => { + const result = getOpenAIConfig( + apiKey, + { + modelOptions: { model: 'gemini-2.5-flash', url_context: true }, + customParams: { defaultParamsEndpoint: 'google' }, + dropParams: ['url_context'], + reverseProxyUrl, + }, + endpoint, + ); + + expect(result.tools).toEqual([]); + }); + + it('should filter out urlContext when url_context is only in modelOptions', () => { + const result = getOpenAIConfig( + apiKey, + { + modelOptions: { model: 'gemini-2.5-flash', url_context: true }, + customParams: { defaultParamsEndpoint: 'google' }, + reverseProxyUrl, + }, + endpoint, + ); + + /** urlContext is filtered since url_context was not explicitly added via addParams/defaultParams */ + expect(result.tools).toEqual([]); + }); + + it('should not enable urlContext on a model that does not support it (Gemini < 2.5)', () => { + const result = getOpenAIConfig( + apiKey, + { + modelOptions: { model: 'gemini-2.0-flash-exp' }, + customParams: { defaultParamsEndpoint: 'google' }, + addParams: { url_context: true }, + reverseProxyUrl, + }, + endpoint, + ); + + expect(result.tools).toEqual([]); + }); + + it('should enable urlContext via defaultParams', () => { + const result = getOpenAIConfig( + apiKey, + { + modelOptions: { model: 'gemini-2.5-flash' }, + customParams: { + defaultParamsEndpoint: 'google', + paramDefinitions: [{ key: 'url_context', default: true }], + }, + reverseProxyUrl, + }, + endpoint, + ); + + expect(result.tools).toEqual([{ urlContext: {} }]); + }); + + it('should preserve both googleSearch and urlContext when both are enabled via addParams', () => { + const result = getOpenAIConfig( + apiKey, + { + modelOptions: { model: 'gemini-2.5-flash' }, + customParams: { defaultParamsEndpoint: 'google' }, + addParams: { web_search: true, url_context: true }, + reverseProxyUrl, + }, + endpoint, + ); + + expect(result.tools).toContainEqual({ googleSearch: {} }); + expect(result.tools).toContainEqual({ urlContext: {} }); + }); + }); + describe('defaultParams Support via customParams', () => { it('should apply defaultParams when fields are undefined', () => { const apiKey = JSON.stringify({ GOOGLE_API_KEY: 'test-google-key' }); diff --git a/packages/api/src/endpoints/openai/transform.ts b/packages/api/src/endpoints/openai/transform.ts index 829d2bd740..bc602358d1 100644 --- a/packages/api/src/endpoints/openai/transform.ts +++ b/packages/api/src/endpoints/openai/transform.ts @@ -16,7 +16,7 @@ const googleExcludeParams = new Set([ ]); /** Google-specific tool types that have no OpenAI-compatible equivalent */ -const googleToolsToFilter = new Set(['googleSearch']); +const googleToolsToFilter = new Set(['googleSearch', 'urlContext']); export type ConfigTools = Array> | Array; @@ -93,8 +93,8 @@ export function transformToOpenAIConfig({ if (addParams && typeof addParams === 'object') { for (const [key, value] of Object.entries(addParams)) { - /** Skip web_search - it's handled separately as a tool */ - if (key === 'web_search') { + /** Skip web_search / url_context - handled separately as native tools */ + if (key === 'web_search' || key === 'url_context') { continue; } @@ -113,8 +113,8 @@ export function transformToOpenAIConfig({ if (dropParams && Array.isArray(dropParams)) { dropParams.forEach((param) => { - /** Skip web_search - handled separately */ - if (param === 'web_search') { + /** Skip web_search / url_context - handled separately */ + if (param === 'web_search' || param === 'url_context') { return; } @@ -134,11 +134,14 @@ export function transformToOpenAIConfig({ /** * Filter out provider-specific tools that have no OpenAI equivalent. - * Exception: If web_search was explicitly enabled via addParams or defaultParams, - * preserve googleSearch tools (pass through in Google-native format). + * Exception: If web_search / url_context was explicitly enabled via addParams + * or defaultParams, preserve the matching Google-native tool (pass through in + * Google-native format). */ const webSearchExplicitlyEnabled = addParams?.web_search === true || defaultParams?.web_search === true; + const urlContextExplicitlyEnabled = + addParams?.url_context === true || defaultParams?.url_context === true; const filterGoogleTool = (tool: unknown): boolean => { if (!isGoogle) { @@ -148,11 +151,15 @@ export function transformToOpenAIConfig({ return false; } const toolKeys = Object.keys(tool as Record); - const isGoogleSpecificTool = toolKeys.some((key) => googleToolsToFilter.has(key)); /** Preserve googleSearch if web_search was explicitly enabled */ - if (isGoogleSpecificTool && webSearchExplicitlyEnabled) { + if (webSearchExplicitlyEnabled && toolKeys.includes('googleSearch')) { return true; } + /** Preserve urlContext if url_context was explicitly enabled */ + if (urlContextExplicitlyEnabled && toolKeys.includes('urlContext')) { + return true; + } + const isGoogleSpecificTool = toolKeys.some((key) => googleToolsToFilter.has(key)); return !isGoogleSpecificTool; }; diff --git a/packages/data-provider/src/parameterSettings.ts b/packages/data-provider/src/parameterSettings.ts index bb746fbf86..44389cb323 100644 --- a/packages/data-provider/src/parameterSettings.ts +++ b/packages/data-provider/src/parameterSettings.ts @@ -765,6 +765,19 @@ const google: Record = { showDefault: false, columnSpan: 2, }, + url_context: { + key: 'url_context', + label: 'com_endpoint_use_url_context', + labelCode: true, + description: 'com_endpoint_google_use_url_context', + descriptionCode: true, + type: 'boolean', + default: false, + component: 'switch', + optionType: 'model', + showDefault: false, + columnSpan: 2, + }, }; const googleConfig: SettingsConfiguration = [ @@ -780,6 +793,7 @@ const googleConfig: SettingsConfiguration = [ google.thinkingBudget, google.thinkingLevel, google.web_search, + google.url_context, librechat.fileTokenLimit, ]; @@ -800,6 +814,7 @@ const googleCol2: SettingsConfiguration = [ google.thinkingBudget, google.thinkingLevel, google.web_search, + google.url_context, librechat.fileTokenLimit, ]; diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 3fafe292d8..6c855156e4 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -954,6 +954,8 @@ export const tConversationSchema = z.object({ thinkingDisplay: eThinkingDisplaySchema.optional().nullable(), /* OpenAI Responses API / Anthropic API / Google API */ web_search: z.boolean().optional(), + /* Google API: URL Context tool (+ native YouTube video understanding) */ + url_context: z.boolean().optional(), /* disable streaming */ disableStreaming: z.boolean().optional(), /* assistant */ @@ -1064,6 +1066,8 @@ export const tQueryParamsSchema = tConversationSchema useResponsesApi: true, /** @endpoints openAI, anthropic, google */ web_search: true, + /** @endpoints google */ + url_context: true, /** @endpoints openAI, custom, azureOpenAI */ disableStreaming: true, /** @endpoints google, anthropic, bedrock */ @@ -1185,6 +1189,7 @@ export const googleBaseSchema = tConversationSchema.pick({ thinkingBudget: true, thinkingLevel: true, web_search: true, + url_context: true, fileTokenLimit: true, iconURL: true, greeting: true, @@ -1219,6 +1224,7 @@ export const googleGenConfigSchema = z }) .optional(), web_search: z.boolean().optional(), + url_context: z.boolean().optional(), }) .strip() .optional(); diff --git a/packages/data-schemas/src/schema/defaults.ts b/packages/data-schemas/src/schema/defaults.ts index 1014843f28..018dc56c65 100644 --- a/packages/data-schemas/src/schema/defaults.ts +++ b/packages/data-schemas/src/schema/defaults.ts @@ -166,6 +166,10 @@ export const conversationPreset: { web_search: { type: BooleanConstructor; }; + /** Google API: URL Context tool (+ native YouTube video understanding) */ + url_context: { + type: BooleanConstructor; + }; disableStreaming: { type: BooleanConstructor; }; @@ -328,6 +332,10 @@ export const conversationPreset: { web_search: { type: Boolean, }, + /** Google API: URL Context tool (+ native YouTube video understanding) */ + url_context: { + type: Boolean, + }, disableStreaming: { type: Boolean, }, diff --git a/packages/data-schemas/src/schema/preset.ts b/packages/data-schemas/src/schema/preset.ts index 5bc438b99b..a4d64879b0 100644 --- a/packages/data-schemas/src/schema/preset.ts +++ b/packages/data-schemas/src/schema/preset.ts @@ -52,6 +52,7 @@ export interface IPreset extends Document { verbosity?: string; useResponsesApi?: boolean; web_search?: boolean; + url_context?: boolean; disableStreaming?: boolean; fileTokenLimit?: number; tenantId?: string; diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index 471b37bcee..1f81948fb3 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -53,6 +53,7 @@ export interface IConversation extends Document { verbosity?: string; useResponsesApi?: boolean; web_search?: boolean; + url_context?: boolean; disableStreaming?: boolean; fileTokenLimit?: number; // Additional fields