@open-mercato/web-research-model 0.7.1-develop.7194.1.ab4fc81f82
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/.turbo/turbo-build.log +2 -0
- package/build.mjs +10 -0
- package/dist/adapter.js +173 -0
- package/dist/adapter.js.map +7 -0
- package/dist/index.js +18 -0
- package/dist/index.js.map +7 -0
- package/dist/nativeTools.js +31 -0
- package/dist/nativeTools.js.map +7 -0
- package/dist/parse.js +47 -0
- package/dist/parse.js.map +7 -0
- package/jest.config.cjs +28 -0
- package/package.json +78 -0
- package/src/__tests__/model.test.ts +248 -0
- package/src/__tests__/nativeTools.test.ts +52 -0
- package/src/adapter.ts +241 -0
- package/src/index.ts +15 -0
- package/src/nativeTools.ts +44 -0
- package/src/parse.ts +71 -0
- package/tsconfig.json +10 -0
- package/watch.mjs +7 -0
package/build.mjs
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { dirname } from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { buildPackage } from '../../scripts/build-package.mjs'
|
|
4
|
+
|
|
5
|
+
const packageDir = dirname(fileURLToPath(import.meta.url))
|
|
6
|
+
|
|
7
|
+
await buildPackage(packageDir, {
|
|
8
|
+
name: 'web-research-model',
|
|
9
|
+
entryPoints: 'src/**/*.ts',
|
|
10
|
+
})
|
package/dist/adapter.js
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTRACT_VERSION,
|
|
3
|
+
READY,
|
|
4
|
+
defineAdapterModule,
|
|
5
|
+
notReady,
|
|
6
|
+
searchOk
|
|
7
|
+
} from "@open-mercato/web-research";
|
|
8
|
+
import { z } from "zod";
|
|
9
|
+
import { loadNativeSearchTool } from "./nativeTools.js";
|
|
10
|
+
import { parseModelSummaries, stripSummaryBlock } from "./parse.js";
|
|
11
|
+
const modelOptionsSchema = z.object({
|
|
12
|
+
maxUses: z.number().int().min(1).max(10).optional(),
|
|
13
|
+
resolveModel: z.custom((value) => typeof value === "function").optional(),
|
|
14
|
+
generate: z.custom((value) => typeof value === "function").optional(),
|
|
15
|
+
loadTools: z.custom((value) => typeof value === "function").optional()
|
|
16
|
+
});
|
|
17
|
+
const DEFAULT_MAX_USES = 3;
|
|
18
|
+
const NOT_CONFIGURED_MARKERS = [
|
|
19
|
+
"exceeded your current quota",
|
|
20
|
+
"insufficient_quota",
|
|
21
|
+
"billing",
|
|
22
|
+
"payment required",
|
|
23
|
+
"invalid api key",
|
|
24
|
+
"incorrect api key",
|
|
25
|
+
"invalid_api_key",
|
|
26
|
+
"authentication",
|
|
27
|
+
"unauthorized",
|
|
28
|
+
"account is not active"
|
|
29
|
+
];
|
|
30
|
+
const PROVIDER_CAVEATS = {
|
|
31
|
+
azure: "searches run via Grounding with Bing, billed separately and outside your geo boundary"
|
|
32
|
+
};
|
|
33
|
+
function isConfigurationFault(message) {
|
|
34
|
+
const haystack = message.toLowerCase();
|
|
35
|
+
return NOT_CONFIGURED_MARKERS.some((marker) => haystack.includes(marker));
|
|
36
|
+
}
|
|
37
|
+
function buildPrompt(request) {
|
|
38
|
+
const constraints = [];
|
|
39
|
+
if (request.site) constraints.push(`Restrict results to the site ${request.site}.`);
|
|
40
|
+
if (request.freshness) constraints.push(`Prefer sources published within the last ${request.freshness}.`);
|
|
41
|
+
if (request.locale) constraints.push(`Prefer sources written for the ${request.locale} locale.`);
|
|
42
|
+
return [
|
|
43
|
+
`Search the public web for: ${request.query}`,
|
|
44
|
+
"Use the web_search tool, then answer in two parts.",
|
|
45
|
+
"",
|
|
46
|
+
"Part 1: a short prose answer citing the most relevant, authoritative sources.",
|
|
47
|
+
"",
|
|
48
|
+
`Part 2: a JSON array in a \`\`\`json code fence with up to ${request.limit} entries, each`,
|
|
49
|
+
'{"url": "...", "title": "...", "summary": "one or two sentences on what this page says about the query"}.',
|
|
50
|
+
"Only include URLs you actually retrieved. Write real summaries, not the page title again.",
|
|
51
|
+
...constraints
|
|
52
|
+
].join("\n");
|
|
53
|
+
}
|
|
54
|
+
async function defaultGenerate(input) {
|
|
55
|
+
const { generateText } = await import("ai");
|
|
56
|
+
return await generateText({
|
|
57
|
+
model: input.model,
|
|
58
|
+
tools: input.tools,
|
|
59
|
+
prompt: input.prompt,
|
|
60
|
+
...input.abortSignal ? { abortSignal: input.abortSignal } : {}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
function createModelAdapter(options) {
|
|
64
|
+
const maxUses = options.maxUses ?? DEFAULT_MAX_USES;
|
|
65
|
+
const resolveModel = options.resolveModel;
|
|
66
|
+
const generate = options.generate ?? defaultGenerate;
|
|
67
|
+
const loadTools = options.loadTools ?? loadNativeSearchTool;
|
|
68
|
+
return {
|
|
69
|
+
id: "model-native",
|
|
70
|
+
kind: "model",
|
|
71
|
+
capabilities: {
|
|
72
|
+
search: true,
|
|
73
|
+
fetch: false,
|
|
74
|
+
snippets: true,
|
|
75
|
+
content: false,
|
|
76
|
+
freshness: true,
|
|
77
|
+
siteFilter: true,
|
|
78
|
+
cost: "llm-tokens"
|
|
79
|
+
},
|
|
80
|
+
/** `free` — resolves the configured model and its tool descriptors; no completion is issued. */
|
|
81
|
+
probeCost: "free",
|
|
82
|
+
readiness: () => resolveModel ? READY : notReady("no model resolver was provided by the host application"),
|
|
83
|
+
async search(request, context) {
|
|
84
|
+
if (!resolveModel) {
|
|
85
|
+
return { status: "unavailable", reason: "no model resolver was provided by the host application" };
|
|
86
|
+
}
|
|
87
|
+
let resolution;
|
|
88
|
+
try {
|
|
89
|
+
resolution = await resolveModel();
|
|
90
|
+
} catch (error) {
|
|
91
|
+
return {
|
|
92
|
+
status: "unavailable",
|
|
93
|
+
reason: `could not resolve a model: ${error instanceof Error ? error.message : String(error)}`
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const tools = await loadTools(resolution.providerId, maxUses);
|
|
97
|
+
if (!tools) {
|
|
98
|
+
return {
|
|
99
|
+
status: "unavailable",
|
|
100
|
+
reason: `provider "${resolution.providerId}" has no native web search available`
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
let result;
|
|
104
|
+
try {
|
|
105
|
+
result = await generate({
|
|
106
|
+
model: resolution.model,
|
|
107
|
+
tools,
|
|
108
|
+
prompt: buildPrompt(request),
|
|
109
|
+
abortSignal: context.signal
|
|
110
|
+
});
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (context.signal.aborted) return { status: "timeout", reason: "model search was aborted" };
|
|
113
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
114
|
+
if (isConfigurationFault(detail)) {
|
|
115
|
+
return { status: "unavailable", reason: `model search is not usable: ${detail}` };
|
|
116
|
+
}
|
|
117
|
+
return { status: "error", reason: `model search failed: ${detail}`, retriable: true };
|
|
118
|
+
}
|
|
119
|
+
const text = result.text ?? "";
|
|
120
|
+
const summaries = new Map(parseModelSummaries(text).map((entry) => [entry.url, entry]));
|
|
121
|
+
const results = [];
|
|
122
|
+
const seen = /* @__PURE__ */ new Set();
|
|
123
|
+
for (const source of result.sources ?? []) {
|
|
124
|
+
if (source.sourceType && source.sourceType !== "url") continue;
|
|
125
|
+
const url = source.url;
|
|
126
|
+
if (!url || seen.has(url)) continue;
|
|
127
|
+
seen.add(url);
|
|
128
|
+
const enrichment = summaries.get(url);
|
|
129
|
+
results.push({
|
|
130
|
+
url,
|
|
131
|
+
title: enrichment?.title ?? source.title ?? null,
|
|
132
|
+
snippet: enrichment?.summary ?? null
|
|
133
|
+
});
|
|
134
|
+
if (results.length >= request.limit) break;
|
|
135
|
+
}
|
|
136
|
+
const answer = stripSummaryBlock(text);
|
|
137
|
+
context.report("ok", `model returned ${results.length} cited source(s)`, {
|
|
138
|
+
resultCount: results.length
|
|
139
|
+
});
|
|
140
|
+
return searchOk(results, answer);
|
|
141
|
+
},
|
|
142
|
+
async healthCheck() {
|
|
143
|
+
if (!resolveModel) return { ok: false, detail: "no model resolver was provided by the host application" };
|
|
144
|
+
try {
|
|
145
|
+
const resolution = await resolveModel();
|
|
146
|
+
const tools = await loadTools(resolution.providerId, maxUses);
|
|
147
|
+
if (!tools) {
|
|
148
|
+
return { ok: false, detail: `provider "${resolution.providerId}" has no native web search` };
|
|
149
|
+
}
|
|
150
|
+
const caveat = PROVIDER_CAVEATS[resolution.providerId];
|
|
151
|
+
return {
|
|
152
|
+
ok: true,
|
|
153
|
+
detail: `${resolution.providerId}/${resolution.modelId}${caveat ? ` - ${caveat}` : ""}`
|
|
154
|
+
};
|
|
155
|
+
} catch (error) {
|
|
156
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
const modelAdapterModule = defineAdapterModule({
|
|
162
|
+
id: "model-native",
|
|
163
|
+
kind: "model",
|
|
164
|
+
contractVersion: CONTRACT_VERSION,
|
|
165
|
+
optionsSchema: modelOptionsSchema,
|
|
166
|
+
createAdapter: createModelAdapter
|
|
167
|
+
});
|
|
168
|
+
export {
|
|
169
|
+
createModelAdapter,
|
|
170
|
+
modelAdapterModule,
|
|
171
|
+
modelOptionsSchema
|
|
172
|
+
};
|
|
173
|
+
//# sourceMappingURL=adapter.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/adapter.ts"],
|
|
4
|
+
"sourcesContent": ["import {\n CONTRACT_VERSION,\n READY,\n defineAdapterModule,\n notReady,\n searchOk,\n type RawResult,\n type SearchAdapter,\n type SearchOutcome,\n type SearchRequest,\n} from '@open-mercato/web-research'\nimport { z } from 'zod'\nimport { loadNativeSearchTool } from './nativeTools'\nimport { parseModelSummaries, stripSummaryBlock } from './parse'\n\nexport type ModelResolution = {\n readonly model: unknown\n readonly providerId: string\n readonly modelId: string\n}\n\nexport type ModelResolver = () => Promise<ModelResolution> | ModelResolution\n\nexport type GenerateSource = { sourceType?: string; url?: string; title?: string }\n\nexport type GenerateResult = {\n readonly text?: string\n readonly sources?: readonly GenerateSource[]\n}\n\nexport type GenerateFn = (input: {\n model: unknown\n tools: Record<string, unknown>\n prompt: string\n abortSignal?: AbortSignal\n}) => Promise<GenerateResult>\n\nexport type LoadToolsFn = (providerId: string, maxUses: number) => Promise<Record<string, unknown> | null>\n\n/**\n * `resolveModel`, `generate` and `loadTools` are host-supplied capabilities rather\n * than stored configuration, so they are validated structurally and merged in by\n * the registry at instantiation time. Keeping them in the same schema means the\n * adapter has one source of truth for what it needs, and `readiness()` can say so\n * plainly instead of failing deep inside a search.\n */\nexport const modelOptionsSchema = z.object({\n maxUses: z.number().int().min(1).max(10).optional(),\n resolveModel: z.custom<ModelResolver>((value) => typeof value === 'function').optional(),\n generate: z.custom<GenerateFn>((value) => typeof value === 'function').optional(),\n loadTools: z.custom<LoadToolsFn>((value) => typeof value === 'function').optional(),\n})\n\nexport type ModelOptions = z.infer<typeof modelOptionsSchema>\n\nconst DEFAULT_MAX_USES = 3\n\n/**\n * A spent quota, a bad key or a suspended account is a configuration problem, not\n * a transient one. Reporting it as `error` tells the scheduler to treat it as\n * bad luck and an operator to wait for it to pass, when the fix is billing or a\n * credential \u2014 so it maps to `unavailable`, which is what \"this source cannot\n * currently work\" means everywhere else in the contract.\n */\nconst NOT_CONFIGURED_MARKERS = [\n 'exceeded your current quota',\n 'insufficient_quota',\n 'billing',\n 'payment required',\n 'invalid api key',\n 'incorrect api key',\n 'invalid_api_key',\n 'authentication',\n 'unauthorized',\n 'account is not active',\n]\n\n/** Shown on the adapter's health row, so it must stay one short clause. */\nconst PROVIDER_CAVEATS: Readonly<Record<string, string>> = {\n azure: 'searches run via Grounding with Bing, billed separately and outside your geo boundary',\n}\n\nfunction isConfigurationFault(message: string): boolean {\n const haystack = message.toLowerCase()\n return NOT_CONFIGURED_MARKERS.some((marker) => haystack.includes(marker))\n}\n\nfunction buildPrompt(request: SearchRequest): string {\n const constraints: string[] = []\n if (request.site) constraints.push(`Restrict results to the site ${request.site}.`)\n if (request.freshness) constraints.push(`Prefer sources published within the last ${request.freshness}.`)\n if (request.locale) constraints.push(`Prefer sources written for the ${request.locale} locale.`)\n\n return [\n `Search the public web for: ${request.query}`,\n 'Use the web_search tool, then answer in two parts.',\n '',\n 'Part 1: a short prose answer citing the most relevant, authoritative sources.',\n '',\n `Part 2: a JSON array in a \\`\\`\\`json code fence with up to ${request.limit} entries, each`,\n '{\"url\": \"...\", \"title\": \"...\", \"summary\": \"one or two sentences on what this page says about the query\"}.',\n 'Only include URLs you actually retrieved. Write real summaries, not the page title again.',\n ...constraints,\n ].join('\\n')\n}\n\nasync function defaultGenerate(input: Parameters<GenerateFn>[0]): Promise<GenerateResult> {\n const { generateText } = await import('ai')\n type GenerateTextInput = Parameters<typeof generateText>[0]\n return (await generateText({\n model: input.model as GenerateTextInput['model'],\n tools: input.tools as GenerateTextInput['tools'],\n prompt: input.prompt,\n ...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),\n })) as GenerateResult\n}\n\nexport function createModelAdapter(options: ModelOptions): SearchAdapter {\n const maxUses = options.maxUses ?? DEFAULT_MAX_USES\n const resolveModel = options.resolveModel\n const generate = options.generate ?? defaultGenerate\n const loadTools = options.loadTools ?? loadNativeSearchTool\n\n return {\n id: 'model-native',\n kind: 'model',\n capabilities: {\n search: true,\n fetch: false,\n snippets: true,\n content: false,\n freshness: true,\n siteFilter: true,\n cost: 'llm-tokens',\n },\n /** `free` \u2014 resolves the configured model and its tool descriptors; no completion is issued. */\n probeCost: 'free' as const,\n readiness: () =>\n resolveModel ? READY : notReady('no model resolver was provided by the host application'),\n\n async search(request, context): Promise<SearchOutcome> {\n if (!resolveModel) {\n return { status: 'unavailable', reason: 'no model resolver was provided by the host application' }\n }\n\n let resolution: ModelResolution\n try {\n resolution = await resolveModel()\n } catch (error) {\n return {\n status: 'unavailable',\n reason: `could not resolve a model: ${error instanceof Error ? error.message : String(error)}`,\n }\n }\n\n const tools = await loadTools(resolution.providerId, maxUses)\n if (!tools) {\n return {\n status: 'unavailable',\n reason: `provider \"${resolution.providerId}\" has no native web search available`,\n }\n }\n\n let result: GenerateResult\n try {\n result = await generate({\n model: resolution.model,\n tools,\n prompt: buildPrompt(request),\n abortSignal: context.signal,\n })\n } catch (error) {\n if (context.signal.aborted) return { status: 'timeout', reason: 'model search was aborted' }\n const detail = error instanceof Error ? error.message : String(error)\n if (isConfigurationFault(detail)) {\n return { status: 'unavailable', reason: `model search is not usable: ${detail}` }\n }\n return { status: 'error', reason: `model search failed: ${detail}`, retriable: true }\n }\n\n const text = result.text ?? ''\n const summaries = new Map(parseModelSummaries(text).map((entry) => [entry.url, entry]))\n\n // Only URLs the search tool actually returned are trusted. The model's own\n // JSON is used solely to enrich those, never to introduce a link, because a\n // fabricated URL that reaches an agent is indistinguishable from a real one.\n const results: RawResult[] = []\n const seen = new Set<string>()\n for (const source of result.sources ?? []) {\n if (source.sourceType && source.sourceType !== 'url') continue\n const url = source.url\n if (!url || seen.has(url)) continue\n seen.add(url)\n const enrichment = summaries.get(url)\n results.push({\n url,\n title: enrichment?.title ?? source.title ?? null,\n snippet: enrichment?.summary ?? null,\n })\n if (results.length >= request.limit) break\n }\n\n const answer = stripSummaryBlock(text)\n context.report('ok', `model returned ${results.length} cited source(s)`, {\n resultCount: results.length,\n })\n\n return searchOk(results, answer)\n },\n\n async healthCheck() {\n if (!resolveModel) return { ok: false, detail: 'no model resolver was provided by the host application' }\n try {\n const resolution = await resolveModel()\n const tools = await loadTools(resolution.providerId, maxUses)\n if (!tools) {\n return { ok: false, detail: `provider \"${resolution.providerId}\" has no native web search` }\n }\n // Azure's native search is Grounding with Bing underneath: it is billed\n // separately and Microsoft states the Data Protection Addendum does not\n // cover it, so queries leave the tenant's compliance and geo boundary.\n // Anyone on Azure for data residency needs to see that, not discover it.\n const caveat = PROVIDER_CAVEATS[resolution.providerId]\n return {\n ok: true,\n detail: `${resolution.providerId}/${resolution.modelId}${caveat ? ` - ${caveat}` : ''}`,\n }\n } catch (error) {\n return { ok: false, detail: error instanceof Error ? error.message : String(error) }\n }\n },\n }\n}\n\nexport const modelAdapterModule = defineAdapterModule({\n id: 'model-native',\n kind: 'model',\n contractVersion: CONTRACT_VERSION,\n optionsSchema: modelOptionsSchema,\n createAdapter: createModelAdapter,\n})\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createModelAdapter,
|
|
3
|
+
modelAdapterModule,
|
|
4
|
+
modelOptionsSchema
|
|
5
|
+
} from "./adapter.js";
|
|
6
|
+
import { parseModelSummaries, stripSummaryBlock } from "./parse.js";
|
|
7
|
+
import { loadNativeSearchTool } from "./nativeTools.js";
|
|
8
|
+
import { modelAdapterModule as modelAdapterModule2 } from "./adapter.js";
|
|
9
|
+
export {
|
|
10
|
+
createModelAdapter,
|
|
11
|
+
modelAdapterModule2 as default,
|
|
12
|
+
loadNativeSearchTool,
|
|
13
|
+
modelAdapterModule,
|
|
14
|
+
modelOptionsSchema,
|
|
15
|
+
parseModelSummaries,
|
|
16
|
+
stripSummaryBlock
|
|
17
|
+
};
|
|
18
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/index.ts"],
|
|
4
|
+
"sourcesContent": ["export {\n createModelAdapter,\n modelAdapterModule,\n modelOptionsSchema,\n type GenerateFn,\n type GenerateResult,\n type GenerateSource,\n type ModelOptions,\n type ModelResolution,\n type ModelResolver,\n} from './adapter'\nexport { parseModelSummaries, stripSummaryBlock, type ModelSummary } from './parse'\nexport { loadNativeSearchTool } from './nativeTools'\n\nexport { modelAdapterModule as default } from './adapter'\n"],
|
|
5
|
+
"mappings": "AAAA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,OAOK;AACP,SAAS,qBAAqB,yBAA4C;AAC1E,SAAS,4BAA4B;AAErC,SAA+B,sBAAtBA,2BAAqC;",
|
|
6
|
+
"names": ["modelAdapterModule"]
|
|
7
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
async function loadNativeSearchTool(providerId, maxUses) {
|
|
2
|
+
if (providerId === "anthropic") {
|
|
3
|
+
try {
|
|
4
|
+
const { anthropic } = await import("@ai-sdk/anthropic");
|
|
5
|
+
return { web_search: anthropic.tools.webSearch_20250305({ maxUses }) };
|
|
6
|
+
} catch {
|
|
7
|
+
return null;
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
if (providerId === "openai") {
|
|
11
|
+
try {
|
|
12
|
+
const { openai } = await import("@ai-sdk/openai");
|
|
13
|
+
return { web_search: openai.tools.webSearch() };
|
|
14
|
+
} catch {
|
|
15
|
+
return null;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (providerId === "azure") {
|
|
19
|
+
try {
|
|
20
|
+
const { azure } = await import("@ai-sdk/azure");
|
|
21
|
+
return { web_search: azure.tools.webSearch() };
|
|
22
|
+
} catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
export {
|
|
29
|
+
loadNativeSearchTool
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=nativeTools.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/nativeTools.ts"],
|
|
4
|
+
"sourcesContent": ["export type NativeToolSet = Record<string, unknown>\n\n/**\n * Loads the provider's own web-search tool lazily.\n *\n * The import is dynamic on purpose: this package is statically imported by the\n * generated adapter registry, and a top-level import of a provider SDK the host\n * did not install would fail the whole registry rather than this one adapter.\n * Both provider packages are optional peers for the same reason.\n */\nexport async function loadNativeSearchTool(\n providerId: string,\n maxUses: number,\n): Promise<NativeToolSet | null> {\n if (providerId === 'anthropic') {\n try {\n const { anthropic } = await import('@ai-sdk/anthropic')\n return { web_search: anthropic.tools.webSearch_20250305({ maxUses }) }\n } catch {\n return null\n }\n }\n if (providerId === 'openai') {\n try {\n const { openai } = await import('@ai-sdk/openai')\n return { web_search: openai.tools.webSearch() }\n } catch {\n return null\n }\n }\n if (providerId === 'azure') {\n try {\n // Azure's web search is a Responses API built-in backed by Grounding with\n // Bing. `@ai-sdk/azure` calls the Responses API by default, which is what\n // makes the tool reachable at all \u2014 the OpenAI-compatible Chat Completions\n // surface Azure used to be wired through cannot carry it.\n const { azure } = await import('@ai-sdk/azure')\n return { web_search: azure.tools.webSearch() }\n } catch {\n return null\n }\n }\n return null\n}\n"],
|
|
5
|
+
"mappings": "AAUA,eAAsB,qBACpB,YACA,SAC+B;AAC/B,MAAI,eAAe,aAAa;AAC9B,QAAI;AACF,YAAM,EAAE,UAAU,IAAI,MAAM,OAAO,mBAAmB;AACtD,aAAO,EAAE,YAAY,UAAU,MAAM,mBAAmB,EAAE,QAAQ,CAAC,EAAE;AAAA,IACvE,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,MAAI,eAAe,UAAU;AAC3B,QAAI;AACF,YAAM,EAAE,OAAO,IAAI,MAAM,OAAO,gBAAgB;AAChD,aAAO,EAAE,YAAY,OAAO,MAAM,UAAU,EAAE;AAAA,IAChD,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,MAAI,eAAe,SAAS;AAC1B,QAAI;AAKF,YAAM,EAAE,MAAM,IAAI,MAAM,OAAO,eAAe;AAC9C,aAAO,EAAE,YAAY,MAAM,MAAM,UAAU,EAAE;AAAA,IAC/C,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/dist/parse.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
const FENCE = /```(?:json)?([\s\S]*?)```/;
|
|
2
|
+
function coerceSummaries(value) {
|
|
3
|
+
if (!Array.isArray(value)) return [];
|
|
4
|
+
const summaries = [];
|
|
5
|
+
for (const item of value) {
|
|
6
|
+
if (typeof item !== "object" || item === null) continue;
|
|
7
|
+
const record = item;
|
|
8
|
+
const url = typeof record.url === "string" ? record.url.trim() : "";
|
|
9
|
+
if (url.length === 0) continue;
|
|
10
|
+
summaries.push({
|
|
11
|
+
url,
|
|
12
|
+
...typeof record.title === "string" && record.title.trim().length > 0 ? { title: record.title.trim() } : {},
|
|
13
|
+
...typeof record.summary === "string" && record.summary.trim().length > 0 ? { summary: record.summary.trim() } : {}
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
return summaries;
|
|
17
|
+
}
|
|
18
|
+
function parseModelSummaries(text) {
|
|
19
|
+
if (text.trim().length === 0) return [];
|
|
20
|
+
const fenced = FENCE.exec(text);
|
|
21
|
+
if (fenced) {
|
|
22
|
+
try {
|
|
23
|
+
return coerceSummaries(JSON.parse(fenced[1]));
|
|
24
|
+
} catch {
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const start = text.indexOf("[");
|
|
28
|
+
const end = text.lastIndexOf("]");
|
|
29
|
+
if (start === -1 || end <= start) return [];
|
|
30
|
+
try {
|
|
31
|
+
return coerceSummaries(JSON.parse(text.slice(start, end + 1)));
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function stripSummaryBlock(text) {
|
|
37
|
+
const withoutFence = text.replace(FENCE, "").trim();
|
|
38
|
+
if (withoutFence.length > 0) return withoutFence;
|
|
39
|
+
const start = text.indexOf("[");
|
|
40
|
+
if (start <= 0) return "";
|
|
41
|
+
return text.slice(0, start).trim();
|
|
42
|
+
}
|
|
43
|
+
export {
|
|
44
|
+
parseModelSummaries,
|
|
45
|
+
stripSummaryBlock
|
|
46
|
+
};
|
|
47
|
+
//# sourceMappingURL=parse.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/parse.ts"],
|
|
4
|
+
"sourcesContent": ["export type ModelSummary = {\n readonly url: string\n readonly title?: string\n readonly summary?: string\n}\n\n/**\n * The leading whitespace stays inside the capture group rather than being eaten\n * by a `\\s*` prefix: `\\s*` and `[\\s\\S]*?` can both match the same whitespace,\n * which makes an unterminated fence backtrack quadratically (CodeQL polynomial\n * ReDoS). The matched span is identical either way, and `JSON.parse` ignores\n * leading whitespace.\n */\nconst FENCE = /```(?:json)?([\\s\\S]*?)```/\n\nfunction coerceSummaries(value: unknown): ModelSummary[] {\n if (!Array.isArray(value)) return []\n const summaries: ModelSummary[] = []\n for (const item of value) {\n if (typeof item !== 'object' || item === null) continue\n const record = item as Record<string, unknown>\n const url = typeof record.url === 'string' ? record.url.trim() : ''\n if (url.length === 0) continue\n summaries.push({\n url,\n ...(typeof record.title === 'string' && record.title.trim().length > 0\n ? { title: record.title.trim() }\n : {}),\n ...(typeof record.summary === 'string' && record.summary.trim().length > 0\n ? { summary: record.summary.trim() }\n : {}),\n })\n }\n return summaries\n}\n\n/**\n * Pulls the per-source summaries out of the model's reply. Best-effort by design:\n * the summaries only ever enrich URLs that the search tool actually returned, so\n * a malformed or absent block costs snippets, never correctness.\n */\nexport function parseModelSummaries(text: string): ModelSummary[] {\n if (text.trim().length === 0) return []\n\n const fenced = FENCE.exec(text)\n if (fenced) {\n try {\n return coerceSummaries(JSON.parse(fenced[1]))\n } catch {\n // fall through to the bare-array scan\n }\n }\n\n const start = text.indexOf('[')\n const end = text.lastIndexOf(']')\n if (start === -1 || end <= start) return []\n try {\n return coerceSummaries(JSON.parse(text.slice(start, end + 1)))\n } catch {\n return []\n }\n}\n\n/** Removes the machine-readable block so the prose answer reads naturally. */\nexport function stripSummaryBlock(text: string): string {\n const withoutFence = text.replace(FENCE, '').trim()\n if (withoutFence.length > 0) return withoutFence\n const start = text.indexOf('[')\n if (start <= 0) return ''\n return text.slice(0, start).trim()\n}\n"],
|
|
5
|
+
"mappings": "AAaA,MAAM,QAAQ;AAEd,SAAS,gBAAgB,OAAgC;AACvD,MAAI,CAAC,MAAM,QAAQ,KAAK,EAAG,QAAO,CAAC;AACnC,QAAM,YAA4B,CAAC;AACnC,aAAW,QAAQ,OAAO;AACxB,QAAI,OAAO,SAAS,YAAY,SAAS,KAAM;AAC/C,UAAM,SAAS;AACf,UAAM,MAAM,OAAO,OAAO,QAAQ,WAAW,OAAO,IAAI,KAAK,IAAI;AACjE,QAAI,IAAI,WAAW,EAAG;AACtB,cAAU,KAAK;AAAA,MACb;AAAA,MACA,GAAI,OAAO,OAAO,UAAU,YAAY,OAAO,MAAM,KAAK,EAAE,SAAS,IACjE,EAAE,OAAO,OAAO,MAAM,KAAK,EAAE,IAC7B,CAAC;AAAA,MACL,GAAI,OAAO,OAAO,YAAY,YAAY,OAAO,QAAQ,KAAK,EAAE,SAAS,IACrE,EAAE,SAAS,OAAO,QAAQ,KAAK,EAAE,IACjC,CAAC;AAAA,IACP,CAAC;AAAA,EACH;AACA,SAAO;AACT;AAOO,SAAS,oBAAoB,MAA8B;AAChE,MAAI,KAAK,KAAK,EAAE,WAAW,EAAG,QAAO,CAAC;AAEtC,QAAM,SAAS,MAAM,KAAK,IAAI;AAC9B,MAAI,QAAQ;AACV,QAAI;AACF,aAAO,gBAAgB,KAAK,MAAM,OAAO,CAAC,CAAC,CAAC;AAAA,IAC9C,QAAQ;AAAA,IAER;AAAA,EACF;AAEA,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,QAAM,MAAM,KAAK,YAAY,GAAG;AAChC,MAAI,UAAU,MAAM,OAAO,MAAO,QAAO,CAAC;AAC1C,MAAI;AACF,WAAO,gBAAgB,KAAK,MAAM,KAAK,MAAM,OAAO,MAAM,CAAC,CAAC,CAAC;AAAA,EAC/D,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAGO,SAAS,kBAAkB,MAAsB;AACtD,QAAM,eAAe,KAAK,QAAQ,OAAO,EAAE,EAAE,KAAK;AAClD,MAAI,aAAa,SAAS,EAAG,QAAO;AACpC,QAAM,QAAQ,KAAK,QAAQ,GAAG;AAC9B,MAAI,SAAS,EAAG,QAAO;AACvB,SAAO,KAAK,MAAM,GAAG,KAAK,EAAE,KAAK;AACnC;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/jest.config.cjs
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/** @type {import('jest').Config} */
|
|
2
|
+
const base = require('../../jest.config.base.cjs')
|
|
3
|
+
|
|
4
|
+
module.exports = {
|
|
5
|
+
...base,
|
|
6
|
+
preset: 'ts-jest',
|
|
7
|
+
testEnvironment: 'node',
|
|
8
|
+
watchman: false,
|
|
9
|
+
rootDir: '.',
|
|
10
|
+
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
|
|
11
|
+
moduleNameMapper: {
|
|
12
|
+
'^@open-mercato/web-research/(.*)$': '<rootDir>/../web-research/src/$1',
|
|
13
|
+
'^@open-mercato/web-research$': '<rootDir>/../web-research/src/index.ts',
|
|
14
|
+
},
|
|
15
|
+
transform: {
|
|
16
|
+
'^.+\\.(t|j)sx?$': [
|
|
17
|
+
'ts-jest',
|
|
18
|
+
{
|
|
19
|
+
tsconfig: {
|
|
20
|
+
rootDir: '.',
|
|
21
|
+
ignoreDeprecations: '6.0',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
|
|
27
|
+
passWithNoTests: true,
|
|
28
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@open-mercato/web-research-model",
|
|
3
|
+
"version": "0.7.1-develop.7194.1.ab4fc81f82",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"openMercato": {
|
|
8
|
+
"webResearchAdapter": {
|
|
9
|
+
"id": "model-native"
|
|
10
|
+
}
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"build": "node build.mjs",
|
|
14
|
+
"watch": "node watch.mjs",
|
|
15
|
+
"test": "jest --config jest.config.cjs",
|
|
16
|
+
"typecheck": "tsc --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
},
|
|
23
|
+
"./*.ts": {
|
|
24
|
+
"types": "./src/*.ts",
|
|
25
|
+
"default": "./dist/*.js"
|
|
26
|
+
},
|
|
27
|
+
"./*": {
|
|
28
|
+
"types": [
|
|
29
|
+
"./src/*.ts"
|
|
30
|
+
],
|
|
31
|
+
"default": "./dist/*.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"@ai-sdk/anthropic": "^4.0.1",
|
|
36
|
+
"@ai-sdk/azure": "^4.0.0",
|
|
37
|
+
"@ai-sdk/openai": "^4.0.2",
|
|
38
|
+
"@open-mercato/web-research": "^0.6.0",
|
|
39
|
+
"ai": "^7.0.4"
|
|
40
|
+
},
|
|
41
|
+
"peerDependenciesMeta": {
|
|
42
|
+
"@ai-sdk/anthropic": {
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"@ai-sdk/azure": {
|
|
46
|
+
"optional": true
|
|
47
|
+
},
|
|
48
|
+
"@ai-sdk/openai": {
|
|
49
|
+
"optional": true
|
|
50
|
+
}
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"zod": "^4.4.3"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@ai-sdk/anthropic": "^4.0.1",
|
|
57
|
+
"@ai-sdk/azure": "^4.0.0",
|
|
58
|
+
"@ai-sdk/openai": "^4.0.2",
|
|
59
|
+
"@open-mercato/web-research": "0.7.1-develop.7194.1.ab4fc81f82",
|
|
60
|
+
"@types/jest": "^30.0.0",
|
|
61
|
+
"@types/node": "^26.0.1",
|
|
62
|
+
"ai": "^7.0.4",
|
|
63
|
+
"esbuild": "^0.28.1",
|
|
64
|
+
"glob": "^13.0.6",
|
|
65
|
+
"jest": "^30.4.2",
|
|
66
|
+
"ts-jest": "^29.4.11",
|
|
67
|
+
"typescript": "^6.0.3"
|
|
68
|
+
},
|
|
69
|
+
"publishConfig": {
|
|
70
|
+
"access": "public"
|
|
71
|
+
},
|
|
72
|
+
"repository": {
|
|
73
|
+
"type": "git",
|
|
74
|
+
"url": "https://github.com/open-mercato/open-mercato",
|
|
75
|
+
"directory": "packages/web-research-model"
|
|
76
|
+
},
|
|
77
|
+
"stableVersion": "0.7.0"
|
|
78
|
+
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import { createTestContext, describeAdapterContract } from '@open-mercato/web-research/testing'
|
|
2
|
+
import { createModelAdapter, modelAdapterModule, type GenerateFn, type ModelResolver } from '../adapter'
|
|
3
|
+
import { parseModelSummaries, stripSummaryBlock } from '../parse'
|
|
4
|
+
|
|
5
|
+
const anthropicResolver: ModelResolver = () => ({
|
|
6
|
+
model: { id: 'fake' },
|
|
7
|
+
providerId: 'anthropic',
|
|
8
|
+
modelId: 'claude-sonnet-5',
|
|
9
|
+
})
|
|
10
|
+
|
|
11
|
+
const REPLY = `Open Mercato is a modular commerce platform.
|
|
12
|
+
|
|
13
|
+
\`\`\`json
|
|
14
|
+
[
|
|
15
|
+
{"url": "https://a.com/1", "title": "Alpha docs", "summary": "Explains the module system in detail."},
|
|
16
|
+
{"url": "https://b.com/2", "title": "Beta post", "summary": "Covers pricing."}
|
|
17
|
+
]
|
|
18
|
+
\`\`\``
|
|
19
|
+
|
|
20
|
+
const generate =
|
|
21
|
+
(result: Awaited<ReturnType<GenerateFn>>): GenerateFn =>
|
|
22
|
+
async () =>
|
|
23
|
+
result
|
|
24
|
+
|
|
25
|
+
/** Stands in for the provider SDK so these tests never load one. */
|
|
26
|
+
const loadTools = async () => ({ web_search: {} })
|
|
27
|
+
|
|
28
|
+
describe('parseModelSummaries', () => {
|
|
29
|
+
it('reads a fenced JSON block', () => {
|
|
30
|
+
expect(parseModelSummaries(REPLY)).toEqual([
|
|
31
|
+
{ url: 'https://a.com/1', title: 'Alpha docs', summary: 'Explains the module system in detail.' },
|
|
32
|
+
{ url: 'https://b.com/2', title: 'Beta post', summary: 'Covers pricing.' },
|
|
33
|
+
])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('reads a bare array when the model skipped the fence', () => {
|
|
37
|
+
expect(parseModelSummaries('Here you go: [{"url":"https://x/1","summary":"s"}]')).toEqual([
|
|
38
|
+
{ url: 'https://x/1', summary: 's' },
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns nothing for malformed or absent JSON', () => {
|
|
43
|
+
expect(parseModelSummaries('no json here')).toEqual([])
|
|
44
|
+
expect(parseModelSummaries('```json\n{ broken ]\n```')).toEqual([])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('drops entries without a url', () => {
|
|
48
|
+
expect(parseModelSummaries('[{"summary":"orphan"},{"url":"https://x/1"}]')).toEqual([
|
|
49
|
+
{ url: 'https://x/1' },
|
|
50
|
+
])
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('stripSummaryBlock', () => {
|
|
55
|
+
it('keeps the prose and drops the machine-readable block', () => {
|
|
56
|
+
expect(stripSummaryBlock(REPLY)).toBe('Open Mercato is a modular commerce platform.')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('model adapter', () => {
|
|
61
|
+
it('is unavailable when the host provided no resolver', async () => {
|
|
62
|
+
const adapter = createModelAdapter({})
|
|
63
|
+
expect(adapter.readiness().ready).toBe(false)
|
|
64
|
+
await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
|
|
65
|
+
status: 'unavailable',
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('attaches model summaries to the URLs the search tool returned', async () => {
|
|
70
|
+
const adapter = createModelAdapter({
|
|
71
|
+
resolveModel: anthropicResolver,
|
|
72
|
+
loadTools,
|
|
73
|
+
generate: generate({
|
|
74
|
+
text: REPLY,
|
|
75
|
+
sources: [
|
|
76
|
+
{ sourceType: 'url', url: 'https://a.com/1', title: 'Raw title' },
|
|
77
|
+
{ sourceType: 'url', url: 'https://b.com/2' },
|
|
78
|
+
],
|
|
79
|
+
}),
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
const outcome = await adapter.search({ query: 'open mercato', limit: 5 }, createTestContext())
|
|
83
|
+
|
|
84
|
+
expect(outcome.status).toBe('ok')
|
|
85
|
+
if (outcome.status !== 'ok') return
|
|
86
|
+
// The old implementation returned snippet: '' here, which is the whole reason
|
|
87
|
+
// the agent had to spend a web_fetch per link to learn anything.
|
|
88
|
+
expect(outcome.results).toEqual([
|
|
89
|
+
{ url: 'https://a.com/1', title: 'Alpha docs', snippet: 'Explains the module system in detail.' },
|
|
90
|
+
{ url: 'https://b.com/2', title: 'Beta post', snippet: 'Covers pricing.' },
|
|
91
|
+
])
|
|
92
|
+
expect(outcome.answer).toBe('Open Mercato is a modular commerce platform.')
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('never introduces a URL the search tool did not return', async () => {
|
|
96
|
+
const adapter = createModelAdapter({
|
|
97
|
+
resolveModel: anthropicResolver,
|
|
98
|
+
loadTools,
|
|
99
|
+
generate: generate({
|
|
100
|
+
text: '[{"url":"https://hallucinated.example/x","summary":"invented"}]',
|
|
101
|
+
sources: [{ sourceType: 'url', url: 'https://real.com/1' }],
|
|
102
|
+
}),
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext())
|
|
106
|
+
|
|
107
|
+
expect(outcome.status).toBe('ok')
|
|
108
|
+
if (outcome.status !== 'ok') return
|
|
109
|
+
expect(outcome.results.map((result) => result.url)).toEqual(['https://real.com/1'])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('keeps a prose answer that cited nothing', async () => {
|
|
113
|
+
const adapter = createModelAdapter({
|
|
114
|
+
resolveModel: anthropicResolver,
|
|
115
|
+
loadTools,
|
|
116
|
+
generate: generate({ text: 'A plain answer with no citations.', sources: [] }),
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
const outcome = await adapter.search({ query: 'x', limit: 5 }, createTestContext())
|
|
120
|
+
|
|
121
|
+
expect(outcome).toMatchObject({ status: 'ok', results: [], answer: 'A plain answer with no citations.' })
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('is empty when the model produced neither links nor prose', async () => {
|
|
125
|
+
const adapter = createModelAdapter({
|
|
126
|
+
resolveModel: anthropicResolver,
|
|
127
|
+
loadTools,
|
|
128
|
+
generate: generate({ text: '', sources: [] }),
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
|
|
132
|
+
status: 'empty',
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('reports a provider without native search as unavailable, not an error', async () => {
|
|
137
|
+
const adapter = createModelAdapter({
|
|
138
|
+
resolveModel: () => ({ model: {}, providerId: 'ollama', modelId: 'llama' }),
|
|
139
|
+
loadTools: async () => null,
|
|
140
|
+
generate: generate({ text: 'x', sources: [] }),
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
|
|
144
|
+
status: 'unavailable',
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('surfaces a resolver failure as unavailable', async () => {
|
|
149
|
+
const adapter = createModelAdapter({
|
|
150
|
+
resolveModel: () => {
|
|
151
|
+
throw new Error('no API key in this process')
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
|
|
156
|
+
status: 'unavailable',
|
|
157
|
+
reason: expect.stringContaining('no API key in this process'),
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('reports a spent quota as unavailable rather than a retriable error', async () => {
|
|
162
|
+
// Seen live against OpenAI: reported as `error` with retriable: true, which
|
|
163
|
+
// reads as bad luck when the fix is billing.
|
|
164
|
+
const adapter = createModelAdapter({
|
|
165
|
+
resolveModel: () => ({ model: {}, providerId: 'openai', modelId: 'gpt' }),
|
|
166
|
+
loadTools: async () => ({ web_search: {} }),
|
|
167
|
+
generate: async () => {
|
|
168
|
+
throw new Error(
|
|
169
|
+
'Failed after 3 attempts. Last error: AI_APICallError: You exceeded your current quota, please check your plan and billing details.',
|
|
170
|
+
)
|
|
171
|
+
},
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
|
|
175
|
+
status: 'unavailable',
|
|
176
|
+
})
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('still reports a genuine transient failure as a retriable error', async () => {
|
|
180
|
+
const adapter = createModelAdapter({
|
|
181
|
+
resolveModel: () => ({ model: {}, providerId: 'openai', modelId: 'gpt' }),
|
|
182
|
+
loadTools: async () => ({ web_search: {} }),
|
|
183
|
+
generate: async () => {
|
|
184
|
+
throw new Error('socket hang up')
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
await expect(adapter.search({ query: 'x', limit: 5 }, createTestContext())).resolves.toMatchObject({
|
|
189
|
+
status: 'error',
|
|
190
|
+
retriable: true,
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('passes the abort signal down and reports a timeout when aborted', async () => {
|
|
195
|
+
const controller = new AbortController()
|
|
196
|
+
let received: AbortSignal | undefined
|
|
197
|
+
const adapter = createModelAdapter({
|
|
198
|
+
resolveModel: anthropicResolver,
|
|
199
|
+
loadTools,
|
|
200
|
+
generate: async (input) => {
|
|
201
|
+
received = input.abortSignal
|
|
202
|
+
controller.abort()
|
|
203
|
+
throw new Error('aborted by test')
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const outcome = await adapter.search(
|
|
208
|
+
{ query: 'x', limit: 5 },
|
|
209
|
+
createTestContext({ signal: controller.signal }),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
expect(received).toBe(controller.signal)
|
|
213
|
+
expect(outcome.status).toBe('timeout')
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
it('caps results at the requested limit', async () => {
|
|
217
|
+
const adapter = createModelAdapter({
|
|
218
|
+
resolveModel: anthropicResolver,
|
|
219
|
+
loadTools,
|
|
220
|
+
generate: generate({
|
|
221
|
+
text: '',
|
|
222
|
+
sources: Array.from({ length: 10 }, (_, index) => ({
|
|
223
|
+
sourceType: 'url',
|
|
224
|
+
url: `https://e.com/${index}`,
|
|
225
|
+
})),
|
|
226
|
+
}),
|
|
227
|
+
})
|
|
228
|
+
|
|
229
|
+
const outcome = await adapter.search({ query: 'x', limit: 3 }, createTestContext())
|
|
230
|
+
|
|
231
|
+
expect(outcome.status).toBe('ok')
|
|
232
|
+
if (outcome.status !== 'ok') return
|
|
233
|
+
expect(outcome.results).toHaveLength(3)
|
|
234
|
+
})
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
describeAdapterContract(
|
|
238
|
+
modelAdapterModule,
|
|
239
|
+
{
|
|
240
|
+
unconfiguredOptions: {},
|
|
241
|
+
configuredOptions: {
|
|
242
|
+
resolveModel: anthropicResolver,
|
|
243
|
+
loadTools,
|
|
244
|
+
generate: generate({ text: '', sources: [{ sourceType: 'url', url: 'https://e.com/1' }] }),
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
{ describe, it, expect: expect as never },
|
|
248
|
+
)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Covers the provider→tool mapping only.
|
|
3
|
+
*
|
|
4
|
+
* The SDK packages are ESM-only and this suite runs through CJS ts-jest, so a
|
|
5
|
+
* real `await import()` degrades to `require()` and lands in the catch — which
|
|
6
|
+
* is exactly why every provider silently looked like it had no native search
|
|
7
|
+
* here. The modules are mocked so the branch logic is asserted; that the real
|
|
8
|
+
* import resolves is proved at runtime, not by this file.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
jest.mock(
|
|
12
|
+
'@ai-sdk/azure',
|
|
13
|
+
() => ({ azure: { tools: { webSearch: () => ({ kind: 'azure-web-search' }) } } }),
|
|
14
|
+
{ virtual: true },
|
|
15
|
+
)
|
|
16
|
+
jest.mock(
|
|
17
|
+
'@ai-sdk/openai',
|
|
18
|
+
() => ({ openai: { tools: { webSearch: () => ({ kind: 'openai-web-search' }) } } }),
|
|
19
|
+
{ virtual: true },
|
|
20
|
+
)
|
|
21
|
+
jest.mock(
|
|
22
|
+
'@ai-sdk/anthropic',
|
|
23
|
+
() => ({
|
|
24
|
+
anthropic: { tools: { webSearch_20250305: () => ({ kind: 'anthropic-web-search' }) } },
|
|
25
|
+
}),
|
|
26
|
+
{ virtual: true },
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
import { loadNativeSearchTool } from '../nativeTools'
|
|
30
|
+
|
|
31
|
+
describe('loadNativeSearchTool', () => {
|
|
32
|
+
it('offers native search on Azure', async () => {
|
|
33
|
+
// Azure has had a Responses API `web_search` built-in for a while, but this
|
|
34
|
+
// returned null for it, so an agent on Foundry was told its provider had no
|
|
35
|
+
// web search at all no matter how capable the deployment was.
|
|
36
|
+
const tools = await loadNativeSearchTool('azure', 3)
|
|
37
|
+
expect(tools).not.toBeNull()
|
|
38
|
+
expect(tools?.web_search).toEqual({ kind: 'azure-web-search' })
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('offers native search on OpenAI and Anthropic', async () => {
|
|
42
|
+
expect((await loadNativeSearchTool('openai', 3))?.web_search).toEqual({ kind: 'openai-web-search' })
|
|
43
|
+
expect((await loadNativeSearchTool('anthropic', 3))?.web_search).toEqual({
|
|
44
|
+
kind: 'anthropic-web-search',
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('returns null for a provider that has none', async () => {
|
|
49
|
+
expect(await loadNativeSearchTool('ollama', 3)).toBeNull()
|
|
50
|
+
expect(await loadNativeSearchTool('lm-studio', 3)).toBeNull()
|
|
51
|
+
})
|
|
52
|
+
})
|
package/src/adapter.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CONTRACT_VERSION,
|
|
3
|
+
READY,
|
|
4
|
+
defineAdapterModule,
|
|
5
|
+
notReady,
|
|
6
|
+
searchOk,
|
|
7
|
+
type RawResult,
|
|
8
|
+
type SearchAdapter,
|
|
9
|
+
type SearchOutcome,
|
|
10
|
+
type SearchRequest,
|
|
11
|
+
} from '@open-mercato/web-research'
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import { loadNativeSearchTool } from './nativeTools'
|
|
14
|
+
import { parseModelSummaries, stripSummaryBlock } from './parse'
|
|
15
|
+
|
|
16
|
+
export type ModelResolution = {
|
|
17
|
+
readonly model: unknown
|
|
18
|
+
readonly providerId: string
|
|
19
|
+
readonly modelId: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ModelResolver = () => Promise<ModelResolution> | ModelResolution
|
|
23
|
+
|
|
24
|
+
export type GenerateSource = { sourceType?: string; url?: string; title?: string }
|
|
25
|
+
|
|
26
|
+
export type GenerateResult = {
|
|
27
|
+
readonly text?: string
|
|
28
|
+
readonly sources?: readonly GenerateSource[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type GenerateFn = (input: {
|
|
32
|
+
model: unknown
|
|
33
|
+
tools: Record<string, unknown>
|
|
34
|
+
prompt: string
|
|
35
|
+
abortSignal?: AbortSignal
|
|
36
|
+
}) => Promise<GenerateResult>
|
|
37
|
+
|
|
38
|
+
export type LoadToolsFn = (providerId: string, maxUses: number) => Promise<Record<string, unknown> | null>
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `resolveModel`, `generate` and `loadTools` are host-supplied capabilities rather
|
|
42
|
+
* than stored configuration, so they are validated structurally and merged in by
|
|
43
|
+
* the registry at instantiation time. Keeping them in the same schema means the
|
|
44
|
+
* adapter has one source of truth for what it needs, and `readiness()` can say so
|
|
45
|
+
* plainly instead of failing deep inside a search.
|
|
46
|
+
*/
|
|
47
|
+
export const modelOptionsSchema = z.object({
|
|
48
|
+
maxUses: z.number().int().min(1).max(10).optional(),
|
|
49
|
+
resolveModel: z.custom<ModelResolver>((value) => typeof value === 'function').optional(),
|
|
50
|
+
generate: z.custom<GenerateFn>((value) => typeof value === 'function').optional(),
|
|
51
|
+
loadTools: z.custom<LoadToolsFn>((value) => typeof value === 'function').optional(),
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
export type ModelOptions = z.infer<typeof modelOptionsSchema>
|
|
55
|
+
|
|
56
|
+
const DEFAULT_MAX_USES = 3
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A spent quota, a bad key or a suspended account is a configuration problem, not
|
|
60
|
+
* a transient one. Reporting it as `error` tells the scheduler to treat it as
|
|
61
|
+
* bad luck and an operator to wait for it to pass, when the fix is billing or a
|
|
62
|
+
* credential — so it maps to `unavailable`, which is what "this source cannot
|
|
63
|
+
* currently work" means everywhere else in the contract.
|
|
64
|
+
*/
|
|
65
|
+
const NOT_CONFIGURED_MARKERS = [
|
|
66
|
+
'exceeded your current quota',
|
|
67
|
+
'insufficient_quota',
|
|
68
|
+
'billing',
|
|
69
|
+
'payment required',
|
|
70
|
+
'invalid api key',
|
|
71
|
+
'incorrect api key',
|
|
72
|
+
'invalid_api_key',
|
|
73
|
+
'authentication',
|
|
74
|
+
'unauthorized',
|
|
75
|
+
'account is not active',
|
|
76
|
+
]
|
|
77
|
+
|
|
78
|
+
/** Shown on the adapter's health row, so it must stay one short clause. */
|
|
79
|
+
const PROVIDER_CAVEATS: Readonly<Record<string, string>> = {
|
|
80
|
+
azure: 'searches run via Grounding with Bing, billed separately and outside your geo boundary',
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isConfigurationFault(message: string): boolean {
|
|
84
|
+
const haystack = message.toLowerCase()
|
|
85
|
+
return NOT_CONFIGURED_MARKERS.some((marker) => haystack.includes(marker))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function buildPrompt(request: SearchRequest): string {
|
|
89
|
+
const constraints: string[] = []
|
|
90
|
+
if (request.site) constraints.push(`Restrict results to the site ${request.site}.`)
|
|
91
|
+
if (request.freshness) constraints.push(`Prefer sources published within the last ${request.freshness}.`)
|
|
92
|
+
if (request.locale) constraints.push(`Prefer sources written for the ${request.locale} locale.`)
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
`Search the public web for: ${request.query}`,
|
|
96
|
+
'Use the web_search tool, then answer in two parts.',
|
|
97
|
+
'',
|
|
98
|
+
'Part 1: a short prose answer citing the most relevant, authoritative sources.',
|
|
99
|
+
'',
|
|
100
|
+
`Part 2: a JSON array in a \`\`\`json code fence with up to ${request.limit} entries, each`,
|
|
101
|
+
'{"url": "...", "title": "...", "summary": "one or two sentences on what this page says about the query"}.',
|
|
102
|
+
'Only include URLs you actually retrieved. Write real summaries, not the page title again.',
|
|
103
|
+
...constraints,
|
|
104
|
+
].join('\n')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async function defaultGenerate(input: Parameters<GenerateFn>[0]): Promise<GenerateResult> {
|
|
108
|
+
const { generateText } = await import('ai')
|
|
109
|
+
type GenerateTextInput = Parameters<typeof generateText>[0]
|
|
110
|
+
return (await generateText({
|
|
111
|
+
model: input.model as GenerateTextInput['model'],
|
|
112
|
+
tools: input.tools as GenerateTextInput['tools'],
|
|
113
|
+
prompt: input.prompt,
|
|
114
|
+
...(input.abortSignal ? { abortSignal: input.abortSignal } : {}),
|
|
115
|
+
})) as GenerateResult
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function createModelAdapter(options: ModelOptions): SearchAdapter {
|
|
119
|
+
const maxUses = options.maxUses ?? DEFAULT_MAX_USES
|
|
120
|
+
const resolveModel = options.resolveModel
|
|
121
|
+
const generate = options.generate ?? defaultGenerate
|
|
122
|
+
const loadTools = options.loadTools ?? loadNativeSearchTool
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
id: 'model-native',
|
|
126
|
+
kind: 'model',
|
|
127
|
+
capabilities: {
|
|
128
|
+
search: true,
|
|
129
|
+
fetch: false,
|
|
130
|
+
snippets: true,
|
|
131
|
+
content: false,
|
|
132
|
+
freshness: true,
|
|
133
|
+
siteFilter: true,
|
|
134
|
+
cost: 'llm-tokens',
|
|
135
|
+
},
|
|
136
|
+
/** `free` — resolves the configured model and its tool descriptors; no completion is issued. */
|
|
137
|
+
probeCost: 'free' as const,
|
|
138
|
+
readiness: () =>
|
|
139
|
+
resolveModel ? READY : notReady('no model resolver was provided by the host application'),
|
|
140
|
+
|
|
141
|
+
async search(request, context): Promise<SearchOutcome> {
|
|
142
|
+
if (!resolveModel) {
|
|
143
|
+
return { status: 'unavailable', reason: 'no model resolver was provided by the host application' }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let resolution: ModelResolution
|
|
147
|
+
try {
|
|
148
|
+
resolution = await resolveModel()
|
|
149
|
+
} catch (error) {
|
|
150
|
+
return {
|
|
151
|
+
status: 'unavailable',
|
|
152
|
+
reason: `could not resolve a model: ${error instanceof Error ? error.message : String(error)}`,
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const tools = await loadTools(resolution.providerId, maxUses)
|
|
157
|
+
if (!tools) {
|
|
158
|
+
return {
|
|
159
|
+
status: 'unavailable',
|
|
160
|
+
reason: `provider "${resolution.providerId}" has no native web search available`,
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let result: GenerateResult
|
|
165
|
+
try {
|
|
166
|
+
result = await generate({
|
|
167
|
+
model: resolution.model,
|
|
168
|
+
tools,
|
|
169
|
+
prompt: buildPrompt(request),
|
|
170
|
+
abortSignal: context.signal,
|
|
171
|
+
})
|
|
172
|
+
} catch (error) {
|
|
173
|
+
if (context.signal.aborted) return { status: 'timeout', reason: 'model search was aborted' }
|
|
174
|
+
const detail = error instanceof Error ? error.message : String(error)
|
|
175
|
+
if (isConfigurationFault(detail)) {
|
|
176
|
+
return { status: 'unavailable', reason: `model search is not usable: ${detail}` }
|
|
177
|
+
}
|
|
178
|
+
return { status: 'error', reason: `model search failed: ${detail}`, retriable: true }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const text = result.text ?? ''
|
|
182
|
+
const summaries = new Map(parseModelSummaries(text).map((entry) => [entry.url, entry]))
|
|
183
|
+
|
|
184
|
+
// Only URLs the search tool actually returned are trusted. The model's own
|
|
185
|
+
// JSON is used solely to enrich those, never to introduce a link, because a
|
|
186
|
+
// fabricated URL that reaches an agent is indistinguishable from a real one.
|
|
187
|
+
const results: RawResult[] = []
|
|
188
|
+
const seen = new Set<string>()
|
|
189
|
+
for (const source of result.sources ?? []) {
|
|
190
|
+
if (source.sourceType && source.sourceType !== 'url') continue
|
|
191
|
+
const url = source.url
|
|
192
|
+
if (!url || seen.has(url)) continue
|
|
193
|
+
seen.add(url)
|
|
194
|
+
const enrichment = summaries.get(url)
|
|
195
|
+
results.push({
|
|
196
|
+
url,
|
|
197
|
+
title: enrichment?.title ?? source.title ?? null,
|
|
198
|
+
snippet: enrichment?.summary ?? null,
|
|
199
|
+
})
|
|
200
|
+
if (results.length >= request.limit) break
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const answer = stripSummaryBlock(text)
|
|
204
|
+
context.report('ok', `model returned ${results.length} cited source(s)`, {
|
|
205
|
+
resultCount: results.length,
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
return searchOk(results, answer)
|
|
209
|
+
},
|
|
210
|
+
|
|
211
|
+
async healthCheck() {
|
|
212
|
+
if (!resolveModel) return { ok: false, detail: 'no model resolver was provided by the host application' }
|
|
213
|
+
try {
|
|
214
|
+
const resolution = await resolveModel()
|
|
215
|
+
const tools = await loadTools(resolution.providerId, maxUses)
|
|
216
|
+
if (!tools) {
|
|
217
|
+
return { ok: false, detail: `provider "${resolution.providerId}" has no native web search` }
|
|
218
|
+
}
|
|
219
|
+
// Azure's native search is Grounding with Bing underneath: it is billed
|
|
220
|
+
// separately and Microsoft states the Data Protection Addendum does not
|
|
221
|
+
// cover it, so queries leave the tenant's compliance and geo boundary.
|
|
222
|
+
// Anyone on Azure for data residency needs to see that, not discover it.
|
|
223
|
+
const caveat = PROVIDER_CAVEATS[resolution.providerId]
|
|
224
|
+
return {
|
|
225
|
+
ok: true,
|
|
226
|
+
detail: `${resolution.providerId}/${resolution.modelId}${caveat ? ` - ${caveat}` : ''}`,
|
|
227
|
+
}
|
|
228
|
+
} catch (error) {
|
|
229
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) }
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export const modelAdapterModule = defineAdapterModule({
|
|
236
|
+
id: 'model-native',
|
|
237
|
+
kind: 'model',
|
|
238
|
+
contractVersion: CONTRACT_VERSION,
|
|
239
|
+
optionsSchema: modelOptionsSchema,
|
|
240
|
+
createAdapter: createModelAdapter,
|
|
241
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
createModelAdapter,
|
|
3
|
+
modelAdapterModule,
|
|
4
|
+
modelOptionsSchema,
|
|
5
|
+
type GenerateFn,
|
|
6
|
+
type GenerateResult,
|
|
7
|
+
type GenerateSource,
|
|
8
|
+
type ModelOptions,
|
|
9
|
+
type ModelResolution,
|
|
10
|
+
type ModelResolver,
|
|
11
|
+
} from './adapter'
|
|
12
|
+
export { parseModelSummaries, stripSummaryBlock, type ModelSummary } from './parse'
|
|
13
|
+
export { loadNativeSearchTool } from './nativeTools'
|
|
14
|
+
|
|
15
|
+
export { modelAdapterModule as default } from './adapter'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
export type NativeToolSet = Record<string, unknown>
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Loads the provider's own web-search tool lazily.
|
|
5
|
+
*
|
|
6
|
+
* The import is dynamic on purpose: this package is statically imported by the
|
|
7
|
+
* generated adapter registry, and a top-level import of a provider SDK the host
|
|
8
|
+
* did not install would fail the whole registry rather than this one adapter.
|
|
9
|
+
* Both provider packages are optional peers for the same reason.
|
|
10
|
+
*/
|
|
11
|
+
export async function loadNativeSearchTool(
|
|
12
|
+
providerId: string,
|
|
13
|
+
maxUses: number,
|
|
14
|
+
): Promise<NativeToolSet | null> {
|
|
15
|
+
if (providerId === 'anthropic') {
|
|
16
|
+
try {
|
|
17
|
+
const { anthropic } = await import('@ai-sdk/anthropic')
|
|
18
|
+
return { web_search: anthropic.tools.webSearch_20250305({ maxUses }) }
|
|
19
|
+
} catch {
|
|
20
|
+
return null
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (providerId === 'openai') {
|
|
24
|
+
try {
|
|
25
|
+
const { openai } = await import('@ai-sdk/openai')
|
|
26
|
+
return { web_search: openai.tools.webSearch() }
|
|
27
|
+
} catch {
|
|
28
|
+
return null
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (providerId === 'azure') {
|
|
32
|
+
try {
|
|
33
|
+
// Azure's web search is a Responses API built-in backed by Grounding with
|
|
34
|
+
// Bing. `@ai-sdk/azure` calls the Responses API by default, which is what
|
|
35
|
+
// makes the tool reachable at all — the OpenAI-compatible Chat Completions
|
|
36
|
+
// surface Azure used to be wired through cannot carry it.
|
|
37
|
+
const { azure } = await import('@ai-sdk/azure')
|
|
38
|
+
return { web_search: azure.tools.webSearch() }
|
|
39
|
+
} catch {
|
|
40
|
+
return null
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return null
|
|
44
|
+
}
|
package/src/parse.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export type ModelSummary = {
|
|
2
|
+
readonly url: string
|
|
3
|
+
readonly title?: string
|
|
4
|
+
readonly summary?: string
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The leading whitespace stays inside the capture group rather than being eaten
|
|
9
|
+
* by a `\s*` prefix: `\s*` and `[\s\S]*?` can both match the same whitespace,
|
|
10
|
+
* which makes an unterminated fence backtrack quadratically (CodeQL polynomial
|
|
11
|
+
* ReDoS). The matched span is identical either way, and `JSON.parse` ignores
|
|
12
|
+
* leading whitespace.
|
|
13
|
+
*/
|
|
14
|
+
const FENCE = /```(?:json)?([\s\S]*?)```/
|
|
15
|
+
|
|
16
|
+
function coerceSummaries(value: unknown): ModelSummary[] {
|
|
17
|
+
if (!Array.isArray(value)) return []
|
|
18
|
+
const summaries: ModelSummary[] = []
|
|
19
|
+
for (const item of value) {
|
|
20
|
+
if (typeof item !== 'object' || item === null) continue
|
|
21
|
+
const record = item as Record<string, unknown>
|
|
22
|
+
const url = typeof record.url === 'string' ? record.url.trim() : ''
|
|
23
|
+
if (url.length === 0) continue
|
|
24
|
+
summaries.push({
|
|
25
|
+
url,
|
|
26
|
+
...(typeof record.title === 'string' && record.title.trim().length > 0
|
|
27
|
+
? { title: record.title.trim() }
|
|
28
|
+
: {}),
|
|
29
|
+
...(typeof record.summary === 'string' && record.summary.trim().length > 0
|
|
30
|
+
? { summary: record.summary.trim() }
|
|
31
|
+
: {}),
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
return summaries
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pulls the per-source summaries out of the model's reply. Best-effort by design:
|
|
39
|
+
* the summaries only ever enrich URLs that the search tool actually returned, so
|
|
40
|
+
* a malformed or absent block costs snippets, never correctness.
|
|
41
|
+
*/
|
|
42
|
+
export function parseModelSummaries(text: string): ModelSummary[] {
|
|
43
|
+
if (text.trim().length === 0) return []
|
|
44
|
+
|
|
45
|
+
const fenced = FENCE.exec(text)
|
|
46
|
+
if (fenced) {
|
|
47
|
+
try {
|
|
48
|
+
return coerceSummaries(JSON.parse(fenced[1]))
|
|
49
|
+
} catch {
|
|
50
|
+
// fall through to the bare-array scan
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const start = text.indexOf('[')
|
|
55
|
+
const end = text.lastIndexOf(']')
|
|
56
|
+
if (start === -1 || end <= start) return []
|
|
57
|
+
try {
|
|
58
|
+
return coerceSummaries(JSON.parse(text.slice(start, end + 1)))
|
|
59
|
+
} catch {
|
|
60
|
+
return []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Removes the machine-readable block so the prose answer reads naturally. */
|
|
65
|
+
export function stripSummaryBlock(text: string): string {
|
|
66
|
+
const withoutFence = text.replace(FENCE, '').trim()
|
|
67
|
+
if (withoutFence.length > 0) return withoutFence
|
|
68
|
+
const start = text.indexOf('[')
|
|
69
|
+
if (start <= 0) return ''
|
|
70
|
+
return text.slice(0, start).trim()
|
|
71
|
+
}
|
package/tsconfig.json
ADDED