Repository navigation
Expand file tree
/
Copy pathai-web-search.ts
More file actions
99 lines (95 loc) · 3.05 KB
/
Copy pathai-web-search.ts
File metadata and controls
99 lines (95 loc) · 3.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
import {
type AiWebSearchResult,
type AiCitationSource,
} from "./ai-tools";
import { openAiKeyHeaders } from "./openai-key";
import type { PhoneLocale } from "./phone-types";
const SEARCH_URL = "/api/ai-web-search";
const MAX_ANSWER = 8_000;
const MAX_SOURCES = 6;
function source(value: unknown): AiCitationSource | undefined {
if (typeof value !== "object" || value === null) return undefined;
const item = value as Record<string, unknown>;
if (typeof item.title !== "string" || typeof item.url !== "string") {
return undefined;
}
try {
const url = new URL(item.url);
if (url.protocol !== "https:") return undefined;
return { title: item.title.slice(0, 160), url: url.toString() };
} catch {
return undefined;
}
}
function parseResult(value: unknown): AiWebSearchResult | undefined {
if (typeof value !== "object" || value === null) return undefined;
const item = value as Record<string, unknown>;
const usage = typeof item.usage === "object" && item.usage !== null
? item.usage as Record<string, unknown>
: undefined;
const sources = Array.isArray(item.sources)
? item.sources.map(source).filter((entry) => entry !== undefined)
: [];
if (
typeof item.answer !== "string"
|| !item.answer.trim()
|| item.answer.length > MAX_ANSWER
|| !usage
) return undefined;
const model = typeof usage.model === "string"
&& usage.model.length > 0
&& usage.model.length <= 120
&& /^[A-Za-z0-9_.:-]+$/.test(usage.model)
? usage.model
: undefined;
const count = (key: string) => typeof usage[key] === "number"
&& Number.isFinite(usage[key])
&& (usage[key] as number) >= 0
&& (usage[key] as number) <= Number.MAX_SAFE_INTEGER
? Math.floor(usage[key] as number)
: undefined;
const inputTokens = count("inputTokens");
const cachedInputTokens = count("cachedInputTokens");
const outputTokens = count("outputTokens");
const webSearchCalls = count("webSearchCalls");
if (
!model
|| inputTokens === undefined
|| cachedInputTokens === undefined
|| cachedInputTokens > inputTokens
|| outputTokens === undefined
|| webSearchCalls === undefined
) return undefined;
return {
answer: item.answer,
sources: sources.slice(0, MAX_SOURCES),
usage: {
model,
inputTokens,
cachedInputTokens,
outputTokens,
webSearchCalls,
},
};
}
export async function requestAiWebSearch(options: {
readonly key: string;
readonly query: string;
readonly locale: PhoneLocale;
readonly signal?: AbortSignal;
readonly fetchImpl?: typeof fetch;
}): Promise<AiWebSearchResult> {
const response = await (options.fetchImpl ?? fetch)(SEARCH_URL, {
method: "POST",
headers: {
...openAiKeyHeaders(options.key),
"Content-Type": "application/json",
},
body: JSON.stringify({ query: options.query, locale: options.locale }),
signal: options.signal,
});
const data: unknown = await response.json();
const parsed = parseResult(data);
if (!response.ok || !parsed) throw new Error("Web search failed");
return parsed;
}