Skip to content

Commit 099bd59

Browse files
feat: add web search support (#144)
* feat: add web search support - Add plugins and web_search_options for web search configuration - Add web search annotations with corrected url_citation schema - Support web search citation processing in responses - Use proper AI SDK LanguageModelV2Source content types for citations Co-Authored-By: Louis <louis@openrouter.ai> * test: add e2e tests for web search functionality - Test plugins array configuration with web search plugin - Test web_search_options for models with built-in web search - Validate LanguageModelV2Source content in streaming responses - Ensure proper citation handling with url, title, and sourceType Co-Authored-By: Louis <louis@openrouter.ai> * refactor: replace any[] with proper type in web search e2e test Co-Authored-By: Louis <louis@openrouter.ai> * refactor: replace any[] with proper type for sources array in web search e2e test Co-Authored-By: Louis <louis@openrouter.ai> * update tests and schema * bump version * fix --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
1 parent aa19b2c commit 099bd59

File tree

6 files changed

+190
-7
lines changed

6 files changed

+190
-7
lines changed

e2e/usage-accounting.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { streamText } from 'ai';
2-
import { it } from 'vitest';
2+
import { it, vi } from 'vitest';
33
import { createOpenRouter } from '@/src';
44

5+
vi.setConfig({
6+
testTimeout: 60_000,
7+
});
8+
59
it('receive usage accounting', async () => {
610
const openrouter = createOpenRouter({
711
apiKey: process.env.OPENROUTER_API_KEY,
@@ -38,6 +42,7 @@ it('receive usage accounting', async () => {
3842
const providerMetadata = await response.providerMetadata;
3943
// You can use expect.any(Type) or expect.objectContaining for schema-like matching
4044
expect(providerMetadata?.openrouter).toMatchObject({
45+
provider: 'Anthropic',
4146
usage: expect.objectContaining({
4247
promptTokens: expect.any(Number),
4348
completionTokens: expect.any(Number),

e2e/web-search/index.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { streamText } from 'ai';
2+
import { writeFile } from 'fs/promises';
3+
import { describe, expect, it, vi } from 'vitest';
4+
import { createOpenRouter } from '@/src';
5+
6+
vi.setConfig({
7+
testTimeout: 60_000,
8+
});
9+
10+
describe('Web Search E2E Tests', () => {
11+
it('should handle web search citations in streaming response', async () => {
12+
const openrouter = createOpenRouter({
13+
apiKey: process.env.OPENROUTER_API_KEY,
14+
baseUrl: `${process.env.OPENROUTER_API_BASE}/api/v1`,
15+
});
16+
17+
const model = openrouter('anthropic/claude-3.5-sonnet', {
18+
plugins: [
19+
{
20+
id: 'web',
21+
max_results: 2,
22+
},
23+
],
24+
usage: {
25+
include: true,
26+
},
27+
});
28+
29+
const response = streamText({
30+
model,
31+
messages: [
32+
{
33+
role: 'user',
34+
content: 'Tell me about the latest SpaceX launch with sources.',
35+
},
36+
],
37+
});
38+
39+
await response.consumeStream();
40+
41+
const sources = await response.sources;
42+
43+
expect(sources.length).toBe(2);
44+
45+
await writeFile(
46+
new URL('./output.ignore.json', import.meta.url),
47+
JSON.stringify(sources, null, 2),
48+
);
49+
});
50+
});

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@openrouter/ai-sdk-provider",
3-
"version": "1.0.0",
3+
"version": "1.1.0",
44
"license": "Apache-2.0",
55
"sideEffects": false,
66
"main": "./dist/index.js",

src/chat/index.ts

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,9 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
131131
reasoning: this.settings.reasoning,
132132
usage: this.settings.usage,
133133

134+
// Web search settings:
135+
plugins: this.settings.plugins,
136+
web_search_options: this.settings.web_search_options,
134137
// Provider routing settings:
135138
provider: this.settings.provider,
136139

@@ -188,6 +191,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
188191
warnings: Array<LanguageModelV2CallWarning>;
189192
providerMetadata?: {
190193
openrouter: {
194+
provider: string;
191195
usage: OpenRouterUsageAccounting;
192196
};
193197
};
@@ -321,13 +325,33 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
321325
}
322326
}
323327

328+
if (choice.message.annotations) {
329+
for (const annotation of choice.message.annotations) {
330+
if (annotation.type === 'url_citation') {
331+
content.push({
332+
type: 'source' as const,
333+
sourceType: 'url' as const,
334+
id: annotation.url_citation.url,
335+
url: annotation.url_citation.url,
336+
title: annotation.url_citation.title,
337+
providerMetadata: {
338+
openrouter: {
339+
content: annotation.url_citation.content || '',
340+
},
341+
},
342+
});
343+
}
344+
}
345+
}
346+
324347
return {
325348
content,
326349
finishReason: mapOpenRouterFinishReason(choice.finish_reason),
327350
usage: usageInfo,
328351
warnings: [],
329352
providerMetadata: {
330353
openrouter: {
354+
provider: response.provider ?? '',
331355
usage: {
332356
promptTokens: usageInfo.inputTokens ?? 0,
333357
completionTokens: usageInfo.outputTokens ?? 0,
@@ -433,6 +457,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
433457
let textId: string | undefined;
434458
let reasoningId: string | undefined;
435459
let openrouterResponseId: string | undefined;
460+
let provider: string | undefined;
436461

437462
return {
438463
stream: response.pipeThrough(
@@ -459,6 +484,10 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
459484
return;
460485
}
461486

487+
if (value.provider) {
488+
provider = value.provider;
489+
}
490+
462491
if (value.id) {
463492
openrouterResponseId = value.id;
464493
controller.enqueue({
@@ -577,7 +606,7 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
577606
});
578607
reasoningStarted = false; // Mark as ended so we don't end it again in flush
579608
}
580-
609+
581610
if (!textStarted) {
582611
textId = openrouterResponseId || generateId();
583612
controller.enqueue({
@@ -593,6 +622,25 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
593622
});
594623
}
595624

625+
if (delta.annotations) {
626+
for (const annotation of delta.annotations) {
627+
if (annotation.type === 'url_citation') {
628+
controller.enqueue({
629+
type: 'source',
630+
sourceType: 'url' as const,
631+
id: annotation.url_citation.url,
632+
url: annotation.url_citation.url,
633+
title: annotation.url_citation.title,
634+
providerMetadata: {
635+
openrouter: {
636+
content: annotation.url_citation.content || '',
637+
},
638+
},
639+
});
640+
}
641+
}
642+
}
643+
596644
if (delta.tool_calls != null) {
597645
for (const toolCallDelta of delta.tool_calls) {
598646
const index = toolCallDelta.index ?? toolCalls.length - 1;
@@ -757,14 +805,24 @@ export class OpenRouterChatLanguageModel implements LanguageModelV2 {
757805
});
758806
}
759807

808+
const openrouterMetadata: {
809+
usage: Partial<OpenRouterUsageAccounting>;
810+
provider?: string;
811+
} = {
812+
usage: openrouterUsage,
813+
};
814+
815+
// Only include provider if it's actually set
816+
if (provider !== undefined) {
817+
openrouterMetadata.provider = provider;
818+
}
819+
760820
controller.enqueue({
761821
type: 'finish',
762822
finishReason,
763823
usage,
764824
providerMetadata: {
765-
openrouter: {
766-
usage: openrouterUsage,
767-
},
825+
openrouter: openrouterMetadata,
768826
},
769827
});
770828
},

src/chat/schemas.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ReasoningDetailArraySchema } from '../schemas/reasoning-details';
55
const OpenRouterChatCompletionBaseResponseSchema = z.object({
66
id: z.string().optional(),
77
model: z.string().optional(),
8+
provider: z.string().optional(),
89
usage: z
910
.object({
1011
prompt_tokens: z.number(),
@@ -53,6 +54,21 @@ export const OpenRouterNonStreamChatCompletionResponseSchema =
5354
}),
5455
)
5556
.optional(),
57+
58+
annotations: z
59+
.array(
60+
z.object({
61+
type: z.enum(['url_citation']),
62+
url_citation: z.object({
63+
end_index: z.number(),
64+
start_index: z.number(),
65+
title: z.string(),
66+
url: z.string(),
67+
content: z.string().optional(),
68+
}),
69+
}),
70+
)
71+
.nullish(),
5672
}),
5773
index: z.number().nullish(),
5874
logprobs: z
@@ -103,6 +119,21 @@ export const OpenRouterStreamChatCompletionChunkSchema = z.union([
103119
}),
104120
)
105121
.nullish(),
122+
123+
annotations: z
124+
.array(
125+
z.object({
126+
type: z.enum(['url_citation']),
127+
url_citation: z.object({
128+
end_index: z.number(),
129+
start_index: z.number(),
130+
title: z.string(),
131+
url: z.string(),
132+
content: z.string().optional(),
133+
}),
134+
}),
135+
)
136+
.nullish(),
106137
})
107138
.nullish(),
108139
logprobs: z

src/types/openrouter-chat-settings.ts

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,35 @@ monitor and detect abuse. Learn more.
4444
*/
4545
user?: string;
4646

47+
/**
48+
* Web search plugin configuration for enabling web search capabilities
49+
*/
50+
plugins?: Array<{
51+
id: 'web';
52+
/**
53+
* Maximum number of search results to include (default: 5)
54+
*/
55+
max_results?: number;
56+
/**
57+
* Custom search prompt to guide the search query
58+
*/
59+
search_prompt?: string;
60+
}>;
61+
62+
/**
63+
* Built-in web search options for models that support native web search
64+
*/
65+
web_search_options?: {
66+
/**
67+
* Maximum number of search results to include
68+
*/
69+
max_results?: number;
70+
/**
71+
* Custom search prompt to guide the search query
72+
*/
73+
search_prompt?: string;
74+
};
75+
4776
/**
4877
* Provider routing preferences to control request routing behavior
4978
*/
@@ -75,7 +104,17 @@ monitor and detect abuse. Learn more.
75104
/**
76105
* List of quantization levels to filter by (e.g. ["int4", "int8"])
77106
*/
78-
quantizations?: Array<'int4' | 'int8' | 'fp4' | 'fp6' | 'fp8' | 'fp16' | 'bf16' | 'fp32' | 'unknown'>;
107+
quantizations?: Array<
108+
| 'int4'
109+
| 'int8'
110+
| 'fp4'
111+
| 'fp6'
112+
| 'fp8'
113+
| 'fp16'
114+
| 'bf16'
115+
| 'fp32'
116+
| 'unknown'
117+
>;
79118
/**
80119
* Sort providers by price, throughput, or latency
81120
*/

0 commit comments

Comments
 (0)