@youdotcom-oss/api 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ import { EXPRESS_API_URL } from '../shared/api.constants.ts'
2
+ import type { GetUserAgent } from '../shared/api.types.ts'
3
+ import { checkResponseForErrors } from '../shared/check-response-for-errors.ts'
4
+ import {
5
+ type ExpressAgentApiResponse,
6
+ ExpressAgentApiResponseSchema,
7
+ type ExpressAgentInput,
8
+ type ExpressAgentMcpResponse,
9
+ } from './express.schemas.ts'
10
+
11
+ /**
12
+ * Checks response status and throws appropriate errors for agent API calls
13
+ */
14
+ const agentThrowOnFailedStatus = async (response: Response) => {
15
+ const errorCode = response.status
16
+
17
+ const errorData = (await response.json()) as {
18
+ errors?: Array<{ detail?: string }>
19
+ }
20
+
21
+ if (errorCode === 400) {
22
+ throw new Error(`Bad Request:\n${JSON.stringify(errorData)}`)
23
+ } else if (errorCode === 401) {
24
+ throw new Error(
25
+ `Unauthorized: The Agent APIs require a valid You.com API key with agent access. Ensure your YDC_API_KEY has permissions for agent endpoints.`,
26
+ )
27
+ } else if (errorCode === 403) {
28
+ throw new Error(`Forbidden: You are not allowed to use the requested tool for this agent or tenant`)
29
+ } else if (errorCode === 429) {
30
+ throw new Error('Rate limited by You.com API. Please try again later.')
31
+ }
32
+ throw new Error(`Failed to call agent. Error code: ${errorCode}`)
33
+ }
34
+
35
+ export const callExpressAgent = async ({
36
+ YDC_API_KEY = process.env.YDC_API_KEY,
37
+ agentInput: { input, tools },
38
+ getUserAgent,
39
+ }: {
40
+ agentInput: ExpressAgentInput
41
+ YDC_API_KEY?: string
42
+ getUserAgent: GetUserAgent
43
+ }) => {
44
+ const requestBody: {
45
+ agent: string
46
+ input: string
47
+ stream: boolean
48
+ tools?: Array<{ type: 'web_search' }>
49
+ } = {
50
+ agent: 'express',
51
+ input,
52
+ stream: false, // Use non-streaming JSON response
53
+ }
54
+
55
+ // Only include tools if provided
56
+ if (tools) {
57
+ requestBody.tools = tools
58
+ }
59
+
60
+ const options = {
61
+ method: 'POST',
62
+ headers: new Headers({
63
+ Authorization: `Bearer ${YDC_API_KEY || ''}`,
64
+ 'Content-Type': 'application/json',
65
+ Accept: 'application/json',
66
+ 'User-Agent': getUserAgent(),
67
+ }),
68
+ body: JSON.stringify(requestBody),
69
+ }
70
+
71
+ const response = await fetch(EXPRESS_API_URL, options)
72
+
73
+ if (!response.ok) {
74
+ await agentThrowOnFailedStatus(response)
75
+ }
76
+
77
+ // Parse JSON response directly
78
+ const jsonResponse = await response.json()
79
+
80
+ // Check for error field in response
81
+ checkResponseForErrors(jsonResponse)
82
+
83
+ // Validate API response schema (full response with all fields)
84
+ const apiResponse: ExpressAgentApiResponse = ExpressAgentApiResponseSchema.parse(jsonResponse)
85
+
86
+ // Find the answer (always present as message.answer, validated by Zod)
87
+ const answerItem = apiResponse.output.find((item) => item.type === 'message.answer')
88
+ if (!answerItem) {
89
+ throw new Error('Express API response missing required message.answer item')
90
+ }
91
+
92
+ // Find search results (optional, present when web_search tool is used)
93
+ const searchItem = apiResponse.output.find((item) => item.type === 'web_search.results')
94
+
95
+ // Transform API response to MCP output format (answer + optional search results, token efficient)
96
+ const mcpResponse: ExpressAgentMcpResponse = {
97
+ answer: answerItem.text,
98
+ agent: apiResponse.agent,
99
+ }
100
+
101
+ // Transform search results if present
102
+ if (searchItem && 'content' in searchItem && Array.isArray(searchItem.content)) {
103
+ mcpResponse.results = {
104
+ web: searchItem.content.map((item) => ({
105
+ url: item.url || item.citation_uri || '',
106
+ title: item.title || '',
107
+ snippet: item.snippet || '',
108
+ })),
109
+ }
110
+ }
111
+
112
+ return mcpResponse
113
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, expect, setDefaultTimeout, test } from 'bun:test'
2
+ import { callExpressAgent } from '../express.utils.ts'
3
+
4
+ const getUserAgent = () => 'API/test (You.com;TEST)'
5
+
6
+ setDefaultTimeout(20_000)
7
+
8
+ describe('callExpressAgent', () => {
9
+ test(
10
+ 'returns answer only (WITHOUT web_search tools)',
11
+ async () => {
12
+ const result = await callExpressAgent({
13
+ agentInput: { input: 'What is machine learning?' },
14
+ getUserAgent,
15
+ })
16
+
17
+ // Verify MCP response structure
18
+ expect(result).toHaveProperty('answer')
19
+ expect(typeof result.answer).toBe('string')
20
+ expect(result.answer.length).toBeGreaterThan(0)
21
+
22
+ // Should NOT have results when web_search is not used
23
+ expect(result.results).toBeUndefined()
24
+
25
+ expect(result.agent).toBe('express')
26
+ },
27
+ { retry: 2 },
28
+ )
29
+
30
+ test(
31
+ 'returns answer and search results (WITH web_search tools)',
32
+ async () => {
33
+ const result = await callExpressAgent({
34
+ agentInput: {
35
+ input: 'Latest developments in quantum computing',
36
+ tools: [{ type: 'web_search' }],
37
+ },
38
+ getUserAgent,
39
+ })
40
+
41
+ // Verify MCP response has both answer and results
42
+ expect(result).toHaveProperty('answer')
43
+ expect(typeof result.answer).toBe('string')
44
+ expect(result.answer.length).toBeGreaterThan(0)
45
+
46
+ expect(result).toHaveProperty('results')
47
+ expect(result.results).toHaveProperty('web')
48
+ expect(Array.isArray(result.results?.web)).toBe(true)
49
+ expect(result.results?.web.length).toBeGreaterThan(0)
50
+
51
+ // Verify each search result has required fields
52
+ const firstResult = result.results?.web[0]
53
+ expect(firstResult).toHaveProperty('url')
54
+ expect(firstResult).toHaveProperty('title')
55
+ expect(firstResult).toHaveProperty('snippet')
56
+ expect(typeof firstResult?.url).toBe('string')
57
+ expect(typeof firstResult?.title).toBe('string')
58
+ expect(typeof firstResult?.snippet).toBe('string')
59
+ expect(firstResult?.url.length).toBeGreaterThan(0)
60
+ expect(firstResult?.title.length).toBeGreaterThan(0)
61
+
62
+ expect(result.agent).toBe('express')
63
+ },
64
+ { timeout: 30_000, retry: 2 },
65
+ )
66
+
67
+ test(
68
+ 'works without optional parameters',
69
+ async () => {
70
+ const result = await callExpressAgent({
71
+ agentInput: { input: 'What is the capital of France?' },
72
+ getUserAgent,
73
+ // No progressToken or sendProgress provided
74
+ })
75
+
76
+ // Should work normally without progress tracking
77
+ expect(result).toHaveProperty('answer')
78
+ expect(result.answer.length).toBeGreaterThan(0)
79
+ expect(result.agent).toBe('express')
80
+ },
81
+ { retry: 2 },
82
+ )
83
+ })
package/src/main.ts ADDED
@@ -0,0 +1,22 @@
1
+ /**
2
+ * @youdotcom-oss/api - You.com API TS client utils
3
+ *
4
+ * This package provides lightweight API client functions and CLI tools
5
+ * for web search, AI answers, and content extraction.
6
+ *
7
+ * @public
8
+ */
9
+
10
+ // Contents
11
+ export * from './contents/contents.schemas.ts'
12
+ export * from './contents/contents.utils.ts'
13
+ // Express
14
+ export * from './express/express.schemas.ts'
15
+ export * from './express/express.utils.ts'
16
+ // Search
17
+ export * from './search/search.schemas.ts'
18
+ export * from './search/search.utils.ts'
19
+ // Shared
20
+ export * from './shared/api.constants.ts'
21
+ export * from './shared/api.types.ts'
22
+ export * from './shared/generate-error-report-link.ts'
@@ -0,0 +1,110 @@
1
+ import * as z from 'zod'
2
+
3
+ export const SearchQuerySchema = z.object({
4
+ query: z.string().min(1, 'Query is required').describe('Search query (supports +, -, site:, filetype:, lang:)'),
5
+ count: z.number().int().min(1).max(100).optional().describe('Max results per section'),
6
+ freshness: z.string().optional().describe('day/week/month/year or YYYY-MM-DDtoYYYY-MM-DD'),
7
+ offset: z.number().int().min(0).max(9).optional().describe('Pagination offset'),
8
+ country: z
9
+ .enum([
10
+ 'AR',
11
+ 'AU',
12
+ 'AT',
13
+ 'BE',
14
+ 'BR',
15
+ 'CA',
16
+ 'CL',
17
+ 'DK',
18
+ 'FI',
19
+ 'FR',
20
+ 'DE',
21
+ 'HK',
22
+ 'IN',
23
+ 'ID',
24
+ 'IT',
25
+ 'JP',
26
+ 'KR',
27
+ 'MY',
28
+ 'MX',
29
+ 'NL',
30
+ 'NZ',
31
+ 'NO',
32
+ 'CN',
33
+ 'PL',
34
+ 'PT',
35
+ 'PH',
36
+ 'RU',
37
+ 'SA',
38
+ 'ZA',
39
+ 'ES',
40
+ 'SE',
41
+ 'CH',
42
+ 'TW',
43
+ 'TR',
44
+ 'GB',
45
+ 'US',
46
+ ])
47
+ .optional()
48
+ .describe('Country code'),
49
+ safesearch: z.enum(['off', 'moderate', 'strict']).optional().describe('Filter level'),
50
+ site: z.string().optional().describe('Specific domain'),
51
+ fileType: z.string().optional().describe('File type'),
52
+ language: z.string().optional().describe('ISO 639-1 language code'),
53
+ excludeTerms: z.string().optional().describe('Terms to exclude (pipe-separated)'),
54
+ exactTerms: z.string().optional().describe('Exact terms (pipe-separated)'),
55
+ livecrawl: z.enum(['web', 'news', 'all']).optional().describe('Live-crawl sections for full content'),
56
+ livecrawl_formats: z.enum(['html', 'markdown']).optional().describe('Format for crawled content'),
57
+ })
58
+
59
+ export type SearchQuery = z.infer<typeof SearchQuerySchema>
60
+
61
+ const WebResultSchema = z.object({
62
+ url: z.string().describe('URL'),
63
+ title: z.string().describe('Title'),
64
+ description: z.string().describe('Description'),
65
+ snippets: z.array(z.string()).describe('Content snippets'),
66
+ page_age: z.string().optional().describe('Publication timestamp'),
67
+ authors: z.array(z.string()).optional().describe('Authors'),
68
+ thumbnail_url: z.string().optional().describe('Thumbnail image URL'),
69
+ favicon_url: z.string().optional().describe('Favicon URL'),
70
+ contents: z
71
+ .object({
72
+ html: z.string().optional().describe('Full HTML content'),
73
+ markdown: z.string().optional().describe('Full Markdown content'),
74
+ })
75
+ .optional()
76
+ .describe('Live-crawled page content'),
77
+ })
78
+
79
+ const NewsResultSchema = z.object({
80
+ title: z.string().describe('Title'),
81
+ description: z.string().describe('Description'),
82
+ page_age: z.string().describe('Publication timestamp'),
83
+ url: z.string().describe('URL'),
84
+ thumbnail_url: z.string().optional().describe('Thumbnail image URL'),
85
+ contents: z
86
+ .object({
87
+ html: z.string().optional().describe('Full HTML content'),
88
+ markdown: z.string().optional().describe('Full Markdown content'),
89
+ })
90
+ .optional()
91
+ .describe('Live-crawled page content'),
92
+ })
93
+
94
+ export type NewsResult = z.infer<typeof NewsResultSchema>
95
+
96
+ const MetadataSchema = z.object({
97
+ search_uuid: z.string().optional().describe('Unique search request ID'),
98
+ query: z.string().describe('Query'),
99
+ latency: z.number().describe('Latency in seconds'),
100
+ })
101
+
102
+ export const SearchResponseSchema = z.object({
103
+ results: z.object({
104
+ web: z.array(WebResultSchema).optional(),
105
+ news: z.array(NewsResultSchema).optional(),
106
+ }),
107
+ metadata: MetadataSchema.partial(),
108
+ })
109
+
110
+ export type SearchResponse = z.infer<typeof SearchResponseSchema>
@@ -0,0 +1,80 @@
1
+ import { SEARCH_API_URL } from '../shared/api.constants.ts'
2
+ import type { GetUserAgent } from '../shared/api.types.ts'
3
+ import { checkResponseForErrors } from '../shared/check-response-for-errors.ts'
4
+ import { type SearchQuery, SearchResponseSchema } from './search.schemas.ts'
5
+
6
+ export const fetchSearchResults = async ({
7
+ YDC_API_KEY = process.env.YDC_API_KEY,
8
+ searchQuery: { query, site, fileType, language, exactTerms, excludeTerms, ...rest },
9
+ getUserAgent,
10
+ }: {
11
+ searchQuery: SearchQuery
12
+ YDC_API_KEY?: string
13
+ getUserAgent: GetUserAgent
14
+ }) => {
15
+ const url = new URL(SEARCH_API_URL)
16
+
17
+ const searchParams = new URLSearchParams()
18
+
19
+ // Build Query Param
20
+ const searchQuery = [query]
21
+ site && searchQuery.push(`site:${site}`)
22
+ fileType && searchQuery.push(`filetype:${fileType}`)
23
+ language && searchQuery.push(`lang:${language}`)
24
+ if (exactTerms && excludeTerms) {
25
+ throw new Error('Cannot specify both exactTerms and excludeTerms - please use only one')
26
+ }
27
+ exactTerms &&
28
+ searchQuery.push(
29
+ exactTerms
30
+ .split('|')
31
+ .map((term) => `+${term}`)
32
+ .join(' AND '),
33
+ )
34
+ excludeTerms &&
35
+ searchQuery.push(
36
+ excludeTerms
37
+ .split('|')
38
+ .map((term) => `-${term}`)
39
+ .join(' AND '),
40
+ )
41
+ searchParams.append('query', searchQuery.join(' '))
42
+
43
+ // Append additional advanced Params
44
+ for (const [name, value] of Object.entries(rest)) {
45
+ if (value) searchParams.append(name, `${value}`)
46
+ }
47
+
48
+ url.search = searchParams.toString()
49
+
50
+ const options = {
51
+ method: 'GET',
52
+ headers: new Headers({
53
+ 'X-API-Key': YDC_API_KEY || '',
54
+ 'User-Agent': getUserAgent(),
55
+ }),
56
+ }
57
+
58
+ const response = await fetch(url, options)
59
+
60
+ if (!response.ok) {
61
+ const errorCode = response.status
62
+
63
+ if (errorCode === 429) {
64
+ throw new Error('Rate limited by You.com API. Please try again later.')
65
+ } else if (errorCode === 403) {
66
+ throw new Error('Forbidden. Please check your You.com API key.')
67
+ }
68
+
69
+ throw new Error(`Failed to perform search. Error code: ${errorCode}`)
70
+ }
71
+
72
+ const results = await response.json()
73
+
74
+ // Check for error field in 200 responses (e.g., API limit errors)
75
+ checkResponseForErrors(results)
76
+
77
+ const parsedResults = SearchResponseSchema.parse(results)
78
+
79
+ return parsedResults
80
+ }
@@ -0,0 +1,135 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { fetchSearchResults } from '../search.utils.ts'
3
+
4
+ const getUserAgent = () => 'API/test (You.com;TEST)'
5
+
6
+ describe('fetchSearchResults', () => {
7
+ test(
8
+ 'returns valid response structure for basic query',
9
+ async () => {
10
+ const result = await fetchSearchResults({
11
+ searchQuery: { query: 'latest stock news' },
12
+ getUserAgent,
13
+ })
14
+
15
+ expect(result).toHaveProperty('results')
16
+ expect(result).toHaveProperty('metadata')
17
+ expect(result.results).toHaveProperty('web')
18
+ // expect(result.results).toHaveProperty('news');
19
+ expect(Array.isArray(result.results.web)).toBe(true)
20
+ // expect(Array.isArray(result.results.news)).toBe(true);
21
+
22
+ // Assert required metadata fields
23
+ expect(typeof result.metadata?.query).toBe('string')
24
+
25
+ // search_uuid is optional but should be string if present
26
+ expect(result.metadata?.search_uuid).toBeDefined()
27
+ expect(typeof result.metadata?.search_uuid).toBe('string')
28
+ },
29
+ { retry: 2 },
30
+ )
31
+
32
+ test(
33
+ 'handles search with filters',
34
+ async () => {
35
+ const result = await fetchSearchResults({
36
+ searchQuery: {
37
+ query: 'javascript tutorial',
38
+ count: 3,
39
+ freshness: 'week',
40
+ country: 'US',
41
+ },
42
+ getUserAgent,
43
+ })
44
+
45
+ expect(result.results.web?.length).toBeLessThanOrEqual(3)
46
+ expect(result.metadata?.query).toContain('javascript tutorial')
47
+ },
48
+ { retry: 2 },
49
+ )
50
+
51
+ test(
52
+ 'validates response schema',
53
+ async () => {
54
+ const result = await fetchSearchResults({
55
+ searchQuery: { query: 'latest technology news' },
56
+ getUserAgent,
57
+ })
58
+
59
+ // Test that web results have required properties
60
+ // biome-ignore lint/style/noNonNullAssertion: Test
61
+ const webResult = result.results.web![0]
62
+
63
+ expect(webResult).toHaveProperty('url')
64
+ expect(webResult).toHaveProperty('title')
65
+ expect(webResult).toHaveProperty('description')
66
+ expect(webResult).toHaveProperty('snippets')
67
+ expect(Array.isArray(webResult?.snippets)).toBe(true)
68
+
69
+ // Test that news results have required properties
70
+ // const newsResult = result.results.news![0];
71
+ // expect(newsResult).toHaveProperty('url');
72
+ // expect(newsResult).toHaveProperty('title');
73
+ // expect(newsResult).toHaveProperty('description');
74
+ // expect(newsResult).toHaveProperty('page_age');
75
+ },
76
+ { retry: 2 },
77
+ )
78
+
79
+ test(
80
+ 'handles livecrawl parameters',
81
+ async () => {
82
+ const result = await fetchSearchResults({
83
+ searchQuery: {
84
+ query: 'python tutorial',
85
+ count: 2,
86
+ livecrawl: 'web',
87
+ livecrawl_formats: 'markdown',
88
+ },
89
+ getUserAgent,
90
+ })
91
+
92
+ expect(result.results.web?.length).toBeLessThanOrEqual(2)
93
+ // Livecrawl should return contents field (fails naturally if not present)
94
+ expect(result.results.web?.[0]).toHaveProperty('contents')
95
+ expect(result.results.web?.[0]?.contents).toHaveProperty('markdown')
96
+ expect(typeof result.results.web?.[0]?.contents?.markdown).toBe('string')
97
+ },
98
+ { retry: 2 },
99
+ )
100
+
101
+ test(
102
+ 'handles freshness date ranges',
103
+ async () => {
104
+ const result = await fetchSearchResults({
105
+ searchQuery: {
106
+ query: 'AI news',
107
+ freshness: '2024-01-01to2024-12-31',
108
+ count: 3,
109
+ },
110
+ getUserAgent,
111
+ })
112
+
113
+ expect(result).toHaveProperty('results')
114
+ expect(result.metadata?.query).toContain('AI news')
115
+ },
116
+ { retry: 2 },
117
+ )
118
+
119
+ test(
120
+ 'handles count greater than 20',
121
+ async () => {
122
+ const result = await fetchSearchResults({
123
+ searchQuery: {
124
+ query: 'machine learning',
125
+ count: 50,
126
+ },
127
+ getUserAgent,
128
+ })
129
+
130
+ expect(result.results.web?.length).toBeGreaterThan(0)
131
+ expect(result.results.web?.length).toBeLessThanOrEqual(50)
132
+ },
133
+ { retry: 2 },
134
+ )
135
+ })
@@ -0,0 +1,10 @@
1
+ /**
2
+ * You.com API endpoints
3
+ *
4
+ * These constants define the base URLs for You.com's APIs.
5
+ * Exported for use in tests and external packages.
6
+ */
7
+
8
+ export const SEARCH_API_URL = 'https://ydc-index.io/v1/search'
9
+ export const EXPRESS_API_URL = 'https://api.you.com/v1/agents/runs'
10
+ export const CONTENTS_API_URL = 'https://ydc-index.io/v1/contents'
@@ -0,0 +1 @@
1
+ export type GetUserAgent = () => string
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Checks if a response object contains an error field and throws if found
3
+ * Handles API responses that return 200 status but contain error messages
4
+ * Used by both search and express agent utilities
5
+ */
6
+ export const checkResponseForErrors = (responseData: unknown) => {
7
+ if (typeof responseData === 'object' && responseData !== null && 'error' in responseData) {
8
+ const errorMessage =
9
+ typeof responseData.error === 'string' ? responseData.error : JSON.stringify(responseData.error)
10
+ throw new Error(`You.com API Error: ${errorMessage}`)
11
+ }
12
+ return responseData
13
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Generates a mailto link for error reporting with pre-filled context
3
+ * Used by tool error handlers to provide easy error reporting
4
+ */
5
+ export const generateErrorReportLink = ({
6
+ errorMessage,
7
+ tool,
8
+ clientInfo,
9
+ }: {
10
+ errorMessage: string
11
+ tool: string
12
+ clientInfo: string
13
+ }): string => {
14
+ const subject = `API Issue ${clientInfo}`
15
+ const body = `Client: ${clientInfo}
16
+ Tool: ${tool}
17
+
18
+ Error Message:
19
+ ${errorMessage}
20
+
21
+ Steps to Reproduce:
22
+ 1.
23
+ 2.
24
+ 3.
25
+
26
+ Additional Context:
27
+ `
28
+
29
+ const params = new URLSearchParams({
30
+ subject,
31
+ body,
32
+ })
33
+
34
+ return `mailto:support@you.com?${params.toString()}`
35
+ }
@@ -0,0 +1,31 @@
1
+ import { describe, expect, test } from 'bun:test'
2
+ import { checkResponseForErrors } from '../check-response-for-errors.ts'
3
+
4
+ describe('checkResponseForErrors', () => {
5
+ test('returns response data when no error field present', () => {
6
+ const responseData = { results: { web: [] }, metadata: {} }
7
+ const result = checkResponseForErrors(responseData)
8
+ expect(result).toEqual(responseData)
9
+ })
10
+
11
+ test('throws when error field is a string', () => {
12
+ const responseData = { error: 'API limit exceeded' }
13
+ expect(() => checkResponseForErrors(responseData)).toThrow('You.com API Error: API limit exceeded')
14
+ })
15
+
16
+ test('throws when error field is an object', () => {
17
+ const responseData = { error: { message: 'Invalid request', code: 400 } }
18
+ expect(() => checkResponseForErrors(responseData)).toThrow('You.com API Error:')
19
+ })
20
+
21
+ test('returns primitive values unchanged', () => {
22
+ expect(checkResponseForErrors('test')).toBe('test')
23
+ expect(checkResponseForErrors(123)).toBe(123)
24
+ expect(checkResponseForErrors(null)).toBe(null)
25
+ })
26
+
27
+ test('returns arrays unchanged', () => {
28
+ const arr = [1, 2, 3]
29
+ expect(checkResponseForErrors(arr)).toEqual(arr)
30
+ })
31
+ })