diff --git a/docs/providers/youcom-provider.md b/docs/providers/youcom-provider.md new file mode 100644 index 00000000000..dc580cd9525 --- /dev/null +++ b/docs/providers/youcom-provider.md @@ -0,0 +1,236 @@ +# You.com Provider Integration + +The YouCom provider integrates You.com's web search into AgentOS, offering access to real-time web information and news search. + +## Overview + +Unlike traditional LLM providers, YouCom specializes in: +- **Real-time web search** with source URLs, descriptions, and snippets +- **News search** with timestamps and publication metadata + +The provider reads credentials from `YDC_API_KEY` or `YOUCOM_API_KEY`, and you can also pass an explicit `apiKey` during initialization. + +## Quick Start + +```typescript +import { YouComProvider } from '@framers/agentos'; + +const provider = new YouComProvider(); +await provider.initialize(); + +const results = await provider.search('What are the latest developments in AI agent frameworks?', { + count: 5, +}); + +for (const item of results.web ?? []) { + console.log(item.title); + console.log(item.url); + console.log(item.description); + console.log(item.snippets[0]); +} +``` + +## Authentication + +### Environment-Based Setup +The provider reads `YDC_API_KEY` first and falls back to `YOUCOM_API_KEY` for legacy setups. + +```bash +export YDC_API_KEY="your_api_key_here" +``` + +Get your API key at [you.com/platform/api-keys](https://you.com/platform/api-keys). + +Alternative environment variable: +```bash +export YOUCOM_API_KEY="your_api_key_here" +``` + +### Custom Configuration + +```typescript +const provider = new YouComProvider(); +await provider.initialize({ + searchApiUrl: 'https://ydc-index.io/v1/search', + mcpServerUrl: 'https://api.you.com/mcp', + debug: true, +}); +``` + +## Available Models + +| Model ID | Description | Use Case | +|----------|-------------|----------| +| `youcom-search` | Web search with snippets | General web search queries | +| `youcom-news` | News-focused search | Recent news and current events | + +## Direct Search API + +Access You.com search functionality directly: + +```typescript +const provider = new YouComProvider(); +await provider.initialize({ + apiKey: process.env.YDC_API_KEY ?? process.env.YOUCOM_API_KEY, +}); + +// Web search +const results = await provider.search('TypeScript frameworks', { + count: 5, + type: 'web' +}); + +// News search +const news = await provider.search('AI developments', { + count: 3, + type: 'news' +}); +``` + +## Response Format + +### Web Search Results +```typescript +{ + web: [ + { + title: "Page title", + url: "https://example.com", + description: "Relevant excerpt from the page...", + snippets: ["Relevant excerpt from the page..."] + } + ] +} +``` + +### News Search Results +```typescript +{ + news: [ + { + title: "Article title", + url: "https://news.example.com/article", + description: "Article excerpt...", + snippets: ["Article excerpt..."], + published_at: "2026-07-26T10:00:00Z" + } + ] +} +``` + +## Error Handling + +The provider handles common error scenarios gracefully: + +- **Rate limiting (429)**: Returns helpful message about API key benefits +- **Network errors**: Fail-safe with informative error messages +- **Invalid queries**: Validation with suggestion prompts +- **Quota exceeded**: Clear indication of limits and upgrade paths + +```typescript +try { + const results = await provider.search('query'); +} catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes('rate limit')) { + console.log('Consider using an API key for higher quotas'); + } +} +``` + +## Health Monitoring + +Check provider connectivity and configuration: + +```typescript +const health = await provider.checkHealth(); +console.log('Healthy:', health.isHealthy); +console.log('API Key configured:', health.details.apiKeyConfigured); +``` + +## AgentOS Registry + +YouCom is automatically registered in AgentOS's provider system: + +```typescript +import { AIModelProviderManager } from '@framers/agentos'; + +const manager = new AIModelProviderManager(); +await manager.initialize({ + providers: [ + { + providerId: 'youcom', + enabled: true, + config: { + apiKey: process.env.YDC_API_KEY ?? process.env.YOUCOM_API_KEY, + }, + }, + ], +}); +``` + +## MCP Server Path + +You.com's MCP surface also includes content and research tooling. This provider +keeps the search integration small and optional, but if you wire the MCP server +later the corresponding tool names are: +- `you-search` for web search +- `you-contents` for URL content extraction +- `you-research` for research synthesis + +Those MCP tools are not enabled by this PR. + +## Limitations + +- **No LLM generation**: YouCom focuses on search/tools, not text generation +- **No embeddings**: Use other providers for embedding models +- **No streaming**: Search results are returned as complete responses +- **Rate limits**: Keyless tier has daily quotas (overcome with API key) + +## Best Practices + +1. **Use for current information**: YouCom excels at real-time web data +2. **Combine with LLM providers**: Use YouCom for search, other providers for generation +3. **Cache results**: Avoid repeated identical searches +4. **Respect rate limits**: Monitor quota usage in production +5. **Cite sources**: Always include URLs in agent responses + +## Examples + +See `examples/youcom-search-example.mjs` for a complete working example demonstrating: +- Direct search and news search with YouComProvider +- Multiple query types +- Direct API access +- Error handling patterns +- Configuration examples + +## Troubleshooting + +### "Provider not initialized" +- Ensure `initialize()` is called before use +- Check network connectivity + +### "Rate limit exceeded" +- Set `YDC_API_KEY` environment variable +- Implement request throttling +- Consider caching search results + +### "Search API connectivity test failed" +- Check internet connection +- Verify You.com API endpoint accessibility +- Review firewall/proxy settings + +### Integration Issues +- Confirm YouCom is registered in `AIModelProviderManager` +- Check provider configuration in AgentOS config +- Enable debug logging: `debug: true` + +## Contributing + +YouCom provider follows AgentOS provider standards: +- Implements full `IProvider` interface +- Comprehensive error handling +- Unit test coverage +- Documentation and examples + +See [Provider Integration Guide](../contributing/new-provider.md) for details. diff --git a/examples/youcom-search-example.mjs b/examples/youcom-search-example.mjs new file mode 100644 index 00000000000..47fc9ea2b43 --- /dev/null +++ b/examples/youcom-search-example.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +/** + * @fileoverview YouCom Provider Example - Demonstrates You.com integration with AgentOS + * + * This example shows how to use the YouCom provider for web search and news search. + * + * Usage: + * node examples/youcom-search-example.mjs + * + * Environment variables: + * YDC_API_KEY - You.com API key + * YOUCOM_API_KEY - Legacy fallback env var + */ + +import { YouComProvider } from '@framers/agentos'; + +function printConfigurationExamples() { + console.log('\nšŸ“š Configuration Examples:\n'); + + console.log('1. Environment-based setup:'); + console.log(' export YDC_API_KEY="your-api-key-here"'); + console.log(' # or export YOUCOM_API_KEY="your-api-key-here"\n'); + + console.log('2. Explicit initialization:'); + console.log(` const provider = new YouComProvider(); + await provider.initialize({ + });\n`); +} + +async function runYouComExample() { + console.log('šŸ” YouCom Provider Example - Search with AgentOS\n'); + + const provider = new YouComProvider(); + await provider.initialize({ debug: true }); + + const webQuery = 'What are the latest developments in AI agent frameworks?'; + console.log(`\nšŸ“‹ Web query: ${webQuery}`); + const webResults = await provider.search(webQuery, { count: 5, type: 'web' }); + + for (const [index, result] of (webResults.web ?? []).entries()) { + console.log(`${index + 1}. ${result.title}`); + console.log(` ${result.url}`); + console.log(` ${result.description}`); + if (result.snippets[0]) { + console.log(` ${result.snippets[0]}`); + } + } + + const newsQuery = 'TypeScript 5.7 release'; + console.log(`\nšŸ“° News query: ${newsQuery}`); + const newsResults = await provider.search(newsQuery, { + count: 3, + type: 'news', + freshness: 'week', + }); + + for (const [index, result] of (newsResults.news ?? []).entries()) { + console.log(`${index + 1}. ${result.title}`); + console.log(` ${result.url}`); + console.log(` ${result.description}`); + if (result.published_at) { + console.log(` published: ${result.published_at}`); + } + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + printConfigurationExamples(); + runYouComExample().catch((error) => { + console.error('āŒ YouCom example failed:', error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 00000000000..23d47580a9c --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,13 @@ +allowBuilds: + '@matrix-org/matrix-sdk-crypto-nodejs': true + '@whiskeysockets/baileys': true + bcrypt: true + better-sqlite3: true + esbuild: true + ffi-napi: true + hnswlib-node: true + onnxruntime-node: true + protobufjs: true + ref-napi: true + sharp: true + tesseract.js: true diff --git a/src/api/model.ts b/src/api/model.ts index dee2edc6bb7..e926d5f9c1d 100644 --- a/src/api/model.ts +++ b/src/api/model.ts @@ -48,6 +48,7 @@ const ENV_KEY_MAP: Record = { xai: 'XAI_API_KEY', stability: 'STABILITY_API_KEY', replicate: 'REPLICATE_API_TOKEN', + youcom: 'YDC_API_KEY', }; const ENV_URL_MAP: Record = { @@ -59,7 +60,7 @@ const ENV_URL_MAP: Record = { 'stable-diffusion-local': 'STABLE_DIFFUSION_LOCAL_BASE_URL', }; -const KEYLESS_PROVIDER_IDS = new Set(['claude-code-cli', 'gemini-cli']); +const KEYLESS_PROVIDER_IDS = new Set(['claude-code-cli', 'gemini-cli', 'youcom']); /** * Splits a `provider:model` string into its constituent parts. diff --git a/src/api/runtime/__tests__/provider-defaults.test.ts b/src/api/runtime/__tests__/provider-defaults.test.ts index 39449d1ccdb..ca442e57c03 100644 --- a/src/api/runtime/__tests__/provider-defaults.test.ts +++ b/src/api/runtime/__tests__/provider-defaults.test.ts @@ -47,6 +47,8 @@ describe('autoDetectProvider', () => { 'TOGETHER_API_KEY', 'MISTRAL_API_KEY', 'XAI_API_KEY', + 'YDC_API_KEY', + 'YOUCOM_API_KEY', 'OLLAMA_BASE_URL', 'STABILITY_API_KEY', 'REPLICATE_API_TOKEN', @@ -97,6 +99,8 @@ describe('autoDetectProvider', () => { delete process.env.TOGETHER_API_KEY; delete process.env.MISTRAL_API_KEY; delete process.env.XAI_API_KEY; + delete process.env.YDC_API_KEY; + delete process.env.YOUCOM_API_KEY; delete process.env.OLLAMA_BASE_URL; delete process.env.STABILITY_API_KEY; delete process.env.REPLICATE_API_TOKEN; @@ -112,6 +116,8 @@ describe('autoDetectProvider', () => { delete process.env.TOGETHER_API_KEY; delete process.env.MISTRAL_API_KEY; delete process.env.XAI_API_KEY; + delete process.env.YDC_API_KEY; + delete process.env.YOUCOM_API_KEY; delete process.env.OLLAMA_BASE_URL; hoisted.spawnSync.mockImplementation((_cmd: string, args?: string[]) => ({ @@ -120,6 +126,24 @@ describe('autoDetectProvider', () => { expect(autoDetectProvider()).toBe('claude-code-cli'); }); + + it('detects youcom from YDC_API_KEY before the legacy fallback', () => { + delete process.env.OPENAI_API_KEY; + delete process.env.OPENROUTER_API_KEY; + delete process.env.ANTHROPIC_API_KEY; + delete process.env.GEMINI_API_KEY; + delete process.env.GROQ_API_KEY; + delete process.env.TOGETHER_API_KEY; + delete process.env.MISTRAL_API_KEY; + delete process.env.XAI_API_KEY; + delete process.env.OLLAMA_BASE_URL; + delete process.env.STABILITY_API_KEY; + delete process.env.REPLICATE_API_TOKEN; + process.env.YDC_API_KEY = 'new-key'; + process.env.YOUCOM_API_KEY = 'legacy-key'; + + expect(autoDetectProvider()).toBe('youcom'); + }); }); describe('resolveModelOption', () => { diff --git a/src/api/runtime/provider-defaults.ts b/src/api/runtime/provider-defaults.ts index a97356542ad..672227a506e 100644 --- a/src/api/runtime/provider-defaults.ts +++ b/src/api/runtime/provider-defaults.ts @@ -131,6 +131,8 @@ const AUTO_DETECT_ORDER: AutoDetectProbe[] = [ { envKey: 'TOGETHER_API_KEY', provider: 'together' }, { envKey: 'MISTRAL_API_KEY', provider: 'mistral' }, { envKey: 'XAI_API_KEY', provider: 'xai' }, + { envKey: 'YDC_API_KEY', provider: 'youcom' }, + { envKey: 'YOUCOM_API_KEY', provider: 'youcom' }, // Fallback for legacy env var { binaryName: 'claude', provider: 'claude-code-cli' }, { binaryName: 'gemini', provider: 'gemini-cli' }, { envKey: 'OLLAMA_BASE_URL', provider: 'ollama' }, @@ -148,11 +150,15 @@ function isBinaryOnPath(binaryName: string): boolean { } // Provider-id → probe lookup so a custom priority list (just provider -// ids) can be resolved back to its env-var or CLI-binary probe. Stays +// ids) can be resolved back to its env-var or CLI-binary probes. Stays // in sync automatically with `AUTO_DETECT_ORDER`. -const PROBE_BY_PROVIDER: Record = Object.fromEntries( - AUTO_DETECT_ORDER.map((probe) => [probe.provider, probe]) -); +const PROBES_BY_PROVIDER: Record = {}; +for (const probe of AUTO_DETECT_ORDER) { + if (!PROBES_BY_PROVIDER[probe.provider]) { + PROBES_BY_PROVIDER[probe.provider] = []; + } + PROBES_BY_PROVIDER[probe.provider].push(probe); +} /** * Auto-detects the active provider by scanning well-known environment variables @@ -171,9 +177,7 @@ const PROBE_BY_PROVIDER: Record = Object.fromEntries( export function autoDetectProvider(task?: ProviderDefaultTask): string | undefined { const customOrder = getProviderPriority(); const order: AutoDetectProbe[] = customOrder - ? customOrder - .map((p) => PROBE_BY_PROVIDER[p]) - .filter((probe): probe is AutoDetectProbe => Boolean(probe)) + ? customOrder.flatMap((p) => PROBES_BY_PROVIDER[p] ?? []) : AUTO_DETECT_ORDER; for (const probe of order) { diff --git a/src/core/llm/providers/AIModelProviderManager.ts b/src/core/llm/providers/AIModelProviderManager.ts index c3e7aa1b1e2..fd8f5589fd1 100644 --- a/src/core/llm/providers/AIModelProviderManager.ts +++ b/src/core/llm/providers/AIModelProviderManager.ts @@ -30,6 +30,7 @@ import { XAIProvider, XAIProviderConfig } from './implementations/XAIProvider'; import { GeminiProvider, GeminiProviderConfig } from './implementations/GeminiProvider'; import { ClaudeCodeProvider, ClaudeCodeProviderConfig } from './implementations/ClaudeCodeProvider'; import { GeminiCLIProvider, GeminiCLIProviderConfig } from './implementations/GeminiCLIProvider'; +import { YouComProvider, YouComProviderConfig } from './implementations/YouComProvider'; import { GMIError, GMIErrorCode, createGMIErrorFromError } from '../../utils/errors.js'; // Corrected import path /** @@ -39,7 +40,7 @@ import { GMIError, GMIErrorCode, createGMIErrorFromError } from '../../utils/err export interface ProviderConfigEntry { providerId: string; enabled: boolean; - config: Partial>; + config: Partial>; isDefault?: boolean; } @@ -153,6 +154,9 @@ export class AIModelProviderManager { case 'gemini-cli': providerInstance = new GeminiCLIProvider(); break; + case 'youcom': + providerInstance = new YouComProvider(); + break; default: console.warn(`AIModelProviderManager: Unknown provider ID '${providerEntry.providerId}'. Skipping.`); continue; diff --git a/src/core/llm/providers/implementations/YouComProvider.ts b/src/core/llm/providers/implementations/YouComProvider.ts new file mode 100644 index 00000000000..f97b5dac05b --- /dev/null +++ b/src/core/llm/providers/implementations/YouComProvider.ts @@ -0,0 +1,417 @@ +// File: backend/agentos/core/llm/providers/implementations/YouComProvider.ts +/** + * @fileoverview You.com search provider integration for AgentOS. Unlike traditional LLM providers, + * this provider focuses on exposing You.com's web and news search capabilities through the + * Search API at https://ydc-index.io/v1/search. + * + * The You.com provider serves as a specialized search provider rather than a text generation + * provider, offering agents access to: + * - Real-time web search + * - News search with publication metadata + * + * The wider You.com platform also exposes MCP tools for content extraction and research + * synthesis, but this provider keeps the integration on the Search API path. + * + * This provider implements IProvider but focuses primarily on search rather than LLM completions. + * For text generation, use a different provider and combine it with You.com search results. + * + * @module backend/agentos/core/llm/providers/implementations/YouComProvider + */ + +import { + IProvider, + ChatMessage, + ModelCompletionOptions, + ModelCompletionResponse, + ProviderEmbeddingOptions, + ProviderEmbeddingResponse, + ModelInfo +} from '../IProvider'; + +/** + * Configuration for YouComProvider + */ +export interface YouComProviderConfig { + /** Optional You.com API key for authenticated MCP server access */ + apiKey?: string; + /** Base URL for You.com Search API (default: https://ydc-index.io/v1/search) */ + searchApiUrl?: string; + /** MCP server URL for authenticated access (default: https://api.you.com/mcp) */ + mcpServerUrl?: string; + /** Fallback LLM provider for text generation when You.com is used as tool augmentation */ + fallbackProvider?: string; + /** Enable debug logging */ + debug?: boolean; +} + +/** + * You.com search result structure + */ +interface YouComSearchResult { + web?: Array<{ + title: string; + url: string; + description: string; + snippets: string[]; + }>; + news?: Array<{ + title: string; + url: string; + description: string; + snippets: string[]; + published_at?: string; + }>; + metadata?: Record; +} + +interface RawYouComSearchResult { + results?: { + web?: unknown[]; + news?: unknown[]; + }; + metadata?: Record; + [key: string]: unknown; +} + +interface YouComSearchOptions { + count?: number; + type?: 'web' | 'news'; + freshness?: 'day' | 'week' | 'month' | 'year' | string; +} + +/** + * YouComProvider - Specialized provider for You.com search capabilities + * + * This provider focuses on tool integration rather than LLM completion, + * offering real-time web search and content access through You.com's APIs. + */ +export class YouComProvider implements IProvider { + public readonly providerId = 'youcom'; + public readonly defaultModelId = 'youcom-search'; // Represents search capability rather than LLM model + private config!: YouComProviderConfig; + private _isInitialized = false; + private static readonly REQUEST_TIMEOUT_MS = 10_000; + + public get isInitialized(): boolean { + return this._isInitialized; + } + + /** + * Initialize the You.com provider with configuration + */ + public async initialize(config: YouComProviderConfig = {}): Promise { + this.config = { + searchApiUrl: 'https://ydc-index.io/v1/search', + mcpServerUrl: 'https://api.you.com/mcp', + debug: false, + ...config + }; + + // Auto-detect API key from environment if not provided + if (!this.config.apiKey) { + this.config.apiKey = process.env.YDC_API_KEY || process.env.YOUCOM_API_KEY; + } + + // Test connectivity to the Search API, but do not fail initialization if + // the host is offline or the API is temporarily unreachable. + await this.testSearchConnectivity(true); + + this._isInitialized = true; + + if (this.config.debug) { + const authMode = this.config.apiKey ? 'authenticated' : 'unauthenticated'; + console.log(`YouComProvider initialized successfully in ${authMode} mode.`); + } + } + + /** + * Test basic connectivity to You.com Search API + */ + private async testSearchConnectivity(logFailures = false): Promise { + try { + const url = new URL(this.config.searchApiUrl!); + url.searchParams.set('query', 'test'); + url.searchParams.set('count', '1'); + + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getSearchHeaders(), + }); + + if (!response.ok && logFailures && this.config.debug) { + console.warn( + `YouComProvider: Search API connectivity test failed with ${response.status} ${response.statusText}.` + ); + } + + return response.ok; + } catch (error) { + if (logFailures && this.config.debug) { + console.warn('YouComProvider: Search API test failed, but continuing initialization:', error); + } + return false; + } + } + + /** + * Get headers for You.com Search API requests + */ + private getSearchHeaders(): Record { + const headers: Record = { + 'User-Agent': 'AgentOS/1.0 (YouComProvider)', + 'Accept': 'application/json', + }; + + if (this.config.apiKey) { + headers['X-API-Key'] = this.config.apiKey; + } + + return headers; + } + + private async fetchWithTimeout(input: RequestInfo | URL, init: RequestInit = {}): Promise { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), YouComProvider.REQUEST_TIMEOUT_MS); + + try { + return await fetch(input, { + ...init, + signal: controller.signal, + }); + } finally { + clearTimeout(timeoutId); + } + } + + private validateCount(count: number): void { + if (!Number.isInteger(count) || count < 1 || count > 100) { + throw new Error('You.com search count must be an integer between 1 and 100.'); + } + } + + private normalizeResultSection(section: unknown): Array<{ + title: string; + url: string; + description: string; + snippets: string[]; + published_at?: string; + }> { + if (!Array.isArray(section)) { + return []; + } + + return section.flatMap((entry) => { + if (!entry || typeof entry !== 'object') { + return []; + } + + const result = entry as Record; + const title = typeof result.title === 'string' ? result.title : ''; + const url = typeof result.url === 'string' ? result.url : ''; + + if (!title || !url) { + return []; + } + + const description = this.pickFirstString(result.description, result.snippet, ''); + const snippets = this.normalizeSnippets(result.snippets, description); + const publishedAt = this.pickFirstString(result.published_at, result.page_age); + + return [ + { + title, + url, + description, + snippets, + ...(publishedAt ? { published_at: publishedAt } : {}), + }, + ]; + }); + } + + private normalizeSnippets(value: unknown, fallback: string): string[] { + if (Array.isArray(value)) { + const snippets = value + .filter((item): item is string => typeof item === 'string') + .map((item) => item.trim()) + .filter(Boolean); + if (snippets.length > 0) { + return snippets; + } + } + + if (typeof value === 'string' && value.trim()) { + return [value.trim()]; + } + + return fallback ? [fallback] : []; + } + + private pickFirstString(...values: unknown[]): string { + for (const value of values) { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + } + return ''; + } + + /** + * Perform You.com web search + */ + public async search(query: string, options: YouComSearchOptions = {}): Promise { + if (!this._isInitialized) { + throw new Error('YouComProvider is not initialized. Call initialize() first.'); + } + + const { count = 10, type = 'web', freshness } = options; + this.validateCount(count); + + try { + const url = new URL(this.config.searchApiUrl!); + url.searchParams.set('query', query); + url.searchParams.set('count', count.toString()); + const effectiveFreshness = freshness ?? (type === 'news' ? 'week' : undefined); + if (effectiveFreshness) { + url.searchParams.set('freshness', effectiveFreshness); + } + + const response = await this.fetchWithTimeout(url, { + method: 'GET', + headers: this.getSearchHeaders(), + }); + + if (!response.ok) { + if (response.status === 429) { + throw new Error('You.com Search API rate limit exceeded. Consider using an API key for higher quotas.'); + } + throw new Error(`Search request failed: ${response.status} ${response.statusText}`); + } + + const data = (await response.json()) as RawYouComSearchResult; + const results = (data.results ?? data) as { web?: unknown[]; news?: unknown[] }; + + return { + web: this.normalizeResultSection(results.web), + news: this.normalizeResultSection(results.news), + ...(data.metadata ? { metadata: data.metadata } : {}), + }; + } catch (error) { + throw new Error(`You.com search failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + /** + * Generate completion - YouComProvider primarily provides tools, not LLM completion + * This method can integrate search context into responses or delegate to fallback providers + */ + public async generateCompletion( + modelId: string, + messages: ChatMessage[], + options: ModelCompletionOptions + ): Promise { + if (!this._isInitialized) { + throw new Error('YouComProvider is not initialized. Call initialize() first.'); + } + + // YouComProvider is designed for search tools, not LLM completion + throw new Error("YouComProvider does not support text completion. Use search() method or configure a different provider for text generation."); + } + + /** + * Streaming completion - Not implemented for YouComProvider + */ + public async *generateCompletionStream( + modelId: string, + messages: ChatMessage[], + options: ModelCompletionOptions + ): AsyncGenerator { + // YouComProvider does not support streaming, yield single error response + const errorResponse: ModelCompletionResponse = { + id: `youcom-error-${Date.now()}`, + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + modelId, + choices: [], + usage: { totalTokens: 0, promptTokens: 0, completionTokens: 0 }, + error: { + message: "YouComProvider does not support streaming completion. Use search() method instead.", + type: "unsupported_operation" + }, + isFinal: true + }; + yield errorResponse; + } + + /** + * Generate embeddings - Not supported by You.com API + */ + public async generateEmbeddings( + modelId: string, + texts: string[], + options?: ProviderEmbeddingOptions + ): Promise { + throw new Error('You.com does not provide embedding models. Use search and content tools instead.'); + } + + /** + * List available "models" - YouComProvider exposes search capabilities as model-like endpoints + */ + public async listAvailableModels(): Promise { + return [ + { + modelId: 'youcom-search', + providerId: this.providerId, + displayName: 'You.com Web Search', + description: 'Real-time web search with snippets and source URLs', + capabilities: ['search', 'tool_use'], + contextWindowSize: undefined, + supportsStreaming: false, + status: 'active', + pricePer1MTokensInput: 0, // Keyless tier is free up to quota + lastUpdated: new Date().toISOString() + }, + { + modelId: 'youcom-news', + providerId: this.providerId, + displayName: 'You.com News Search', + description: 'Real-time news search with timestamps and sources', + capabilities: ['search', 'tool_use'], + contextWindowSize: undefined, + supportsStreaming: false, + status: 'active', + pricePer1MTokensInput: 0, + lastUpdated: new Date().toISOString() + } + ]; + } + + /** + * Get model info for You.com search capabilities + */ + public async getModelInfo(modelId: string): Promise { + const models = await this.listAvailableModels(); + return models.find(model => model.modelId === modelId); + } + + /** + * Check provider health + */ + public async checkHealth(): Promise<{ isHealthy: boolean; details?: unknown }> { + const isHealthy = await this.testSearchConnectivity(false); + return { + isHealthy, + details: { apiKeyConfigured: Boolean(this.config.apiKey) } + }; + } + + /** + * Shutdown provider + */ + public async shutdown(): Promise { + this._isInitialized = false; + if (this.config.debug) { + console.log('YouComProvider shutdown complete.'); + } + } +} diff --git a/src/index.ts b/src/index.ts index 6a3197c499c..4cf6d543dd8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export * from './core/conversation/ILongTermMemoryRetriever'; export * from './core/conversation/LongTermMemoryPolicy'; export * from './core/streaming/StreamingManager'; export * from './core/llm/providers/AIModelProviderManager'; +export { YouComProvider } from './core/llm/providers/implementations/YouComProvider.js'; export * from './orchestration/turn-planner/TurnPlanner'; export * from './orchestration/turn-planner/SqlTaskOutcomeTelemetryStore'; export * from './orchestration/workflows/WorkflowTypes'; diff --git a/tests/youcom-integration.test.ts b/tests/youcom-integration.test.ts new file mode 100644 index 00000000000..f5a09b60db9 --- /dev/null +++ b/tests/youcom-integration.test.ts @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AIModelProviderManager } from '../src/core/llm/providers/AIModelProviderManager'; +import { YouComProvider } from '../src/core/llm/providers/implementations/YouComProvider'; + +type MockResponseInit = { + ok?: boolean; + status?: number; + statusText?: string; +}; + +function mockResponse(body: unknown, init: MockResponseInit = {}): Response { + const status = init.status ?? 200; + const ok = init.ok ?? (status >= 200 && status < 300); + return { + ok, + status, + statusText: init.statusText ?? (ok ? 'OK' : 'Error'), + json: async () => body, + } as Response; +} + +describe('YouComProvider Integration', () => { + let provider: YouComProvider; + let fetchMock: ReturnType; + + beforeEach(() => { + provider = new YouComProvider(); + fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + vi.stubEnv('YDC_API_KEY', ''); + vi.stubEnv('YOUCOM_API_KEY', ''); + }); + + afterEach(async () => { + if (provider.isInitialized) { + await provider.shutdown(); + } + vi.unstubAllGlobals(); + vi.unstubAllEnvs(); + }); + + it('initializes successfully and exposes its provider metadata', async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })); + + await provider.initialize({}); + + expect(provider.isInitialized).toBe(true); + expect(provider.providerId).toBe('youcom'); + expect(provider.defaultModelId).toBe('youcom-search'); + }); + + it('lists available models', async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })); + + await provider.initialize({}); + const models = await provider.listAvailableModels(); + + expect(models).toHaveLength(2); + expect(models[0].modelId).toBe('youcom-search'); + expect(models[0].displayName).toBe('You.com Web Search'); + expect(models[0].capabilities).toContain('search'); + expect(models[1].modelId).toBe('youcom-news'); + }); + + it('normalizes search responses and uses the documented endpoint and auth header', async () => { + fetchMock + .mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })) + .mockResolvedValueOnce( + mockResponse({ + results: { + web: [ + { + title: 'AgentOS docs', + url: 'https://example.com/agentos', + description: 'AgentOS docs overview', + snippets: ['AgentOS docs overview', 'More detail'], + }, + ], + news: [ + { + title: 'You.com news item', + url: 'https://news.example.com/youcom', + description: 'A recent You.com update', + snippets: ['A recent You.com update'], + published_at: '2026-07-26T10:00:00Z', + }, + ], + }, + metadata: { search_uuid: 'abc-123' }, + }) + ); + + await provider.initialize({ apiKey: 'test-key' }); + const result = await provider.search('TypeScript AI agent frameworks', { count: 3 }); + + expect(fetchMock).toHaveBeenCalledTimes(2); + + const [requestInput, requestInit] = fetchMock.mock.calls[1]; + const requestUrl = new URL(String(requestInput)); + + expect(`${requestUrl.origin}${requestUrl.pathname}`).toBe('https://ydc-index.io/v1/search'); + expect(requestUrl.searchParams.get('query')).toBe('TypeScript AI agent frameworks'); + expect(requestUrl.searchParams.get('count')).toBe('3'); + expect(requestUrl.searchParams.get('freshness')).toBeNull(); + expect(requestInit).toMatchObject({ + method: 'GET', + headers: { + 'User-Agent': 'AgentOS/1.0 (YouComProvider)', + Accept: 'application/json', + 'X-API-Key': 'test-key', + }, + }); + + expect(result.web).toEqual([ + { + title: 'AgentOS docs', + url: 'https://example.com/agentos', + description: 'AgentOS docs overview', + snippets: ['AgentOS docs overview', 'More detail'], + }, + ]); + expect(result.news).toEqual([ + { + title: 'You.com news item', + url: 'https://news.example.com/youcom', + description: 'A recent You.com update', + snippets: ['A recent You.com update'], + published_at: '2026-07-26T10:00:00Z', + }, + ]); + expect(result.metadata).toEqual({ search_uuid: 'abc-123' }); + }); + + it('defaults news searches to a freshness hint instead of sending an undocumented type parameter', async () => { + fetchMock + .mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })) + .mockResolvedValueOnce(mockResponse({ results: { news: [] }, metadata: {} })); + + await provider.initialize({}); + await provider.search('AI agent frameworks', { type: 'news', count: 5 }); + + const requestUrl = new URL(String(fetchMock.mock.calls[1][0])); + expect(requestUrl.searchParams.get('type')).toBeNull(); + expect(requestUrl.searchParams.get('freshness')).toBe('week'); + }); + + it('reports unhealthy when the connectivity probe fails', async () => { + fetchMock + .mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })) + .mockResolvedValueOnce(mockResponse({ results: {}, metadata: {} }, { ok: false, status: 503, statusText: 'Service Unavailable' })); + + await provider.initialize({}); + const health = await provider.checkHealth(); + + expect(health.isHealthy).toBe(false); + expect(health.details).toEqual({ apiKeyConfigured: false }); + }); + + it('rejects invalid explicit counts before issuing a request', async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })); + + await provider.initialize({}); + await expect(provider.search('TypeScript AI agent frameworks', { count: 0 })).rejects.toThrow( + 'You.com search count must be an integer between 1 and 100.' + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it('throws for unsupported completion generation', async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })); + + await provider.initialize({}); + await expect( + provider.generateCompletion('youcom-search', [{ role: 'user', content: 'Hello' }], {}) + ).rejects.toThrow('YouComProvider does not support text completion'); + }); + + it('registers through the provider manager', async () => { + fetchMock.mockResolvedValueOnce(mockResponse({ results: {}, metadata: { query: 'test' } })); + + const manager = new AIModelProviderManager(); + await manager.initialize({ + providers: [ + { + providerId: 'youcom', + enabled: true, + config: {}, + }, + ], + }); + + const resolved = manager.getProvider('youcom'); + + expect(resolved).toBeDefined(); + expect(resolved?.providerId).toBe('youcom'); + expect(resolved?.defaultModelId).toBe('youcom-search'); + }); +});