@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.
- package/README.md +551 -0
- package/bin/cli.js +5483 -0
- package/package.json +68 -0
- package/src/cli.ts +98 -0
- package/src/commands/contents.ts +52 -0
- package/src/commands/express.ts +52 -0
- package/src/commands/search.ts +52 -0
- package/src/contents/contents.schemas.ts +46 -0
- package/src/contents/contents.utils.ts +98 -0
- package/src/contents/tests/contents.utils.spec.ts +78 -0
- package/src/express/express.schemas.ts +77 -0
- package/src/express/express.utils.ts +113 -0
- package/src/express/tests/express.utils.spec.ts +83 -0
- package/src/main.ts +22 -0
- package/src/search/search.schemas.ts +110 -0
- package/src/search/search.utils.ts +80 -0
- package/src/search/tests/search.utils.spec.ts +135 -0
- package/src/shared/api.constants.ts +10 -0
- package/src/shared/api.types.ts +1 -0
- package/src/shared/check-response-for-errors.ts +13 -0
- package/src/shared/generate-error-report-link.ts +35 -0
- package/src/shared/tests/check-response-for-errors.spec.ts +31 -0
- package/src/shared/tests/format-search-results-text.spec.ts +85 -0
- package/src/shared/use-get-user-agents.ts +8 -0
|
@@ -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
|
+
})
|