Skip to content

Commit 527fa10

Browse files
fix(chat,embedding): forward extraBody and custom fetch to SDK
Fixes gaps discovered via hyperslice analysis: - gap-917: extraBody now spread into request params (allows arbitrary additional fields to be passed to the API) - gap-918: Custom fetch now passed to OpenRouter SDK via HTTPClient (enables custom logging, proxies, and testing with mock fetch) Note: gap-919 (usage.include) was investigated but the Responses API always returns usage information by default - the option is not applicable. Users needing Chat Completions API-specific options can use extraBody. Added test coverage for: - extraBody being spread into request params (doGenerate/doStream) - Explicit params override extraBody fields - Custom fetch creates HTTPClient and passes to SDK - No HTTPClient when no custom fetch provided
1 parent d4c82a6 commit 527fa10

File tree

3 files changed

+192
-26
lines changed

3 files changed

+192
-26
lines changed

src/__tests__/chat/openrouter-chat-language-model.test.ts

Lines changed: 165 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { LanguageModelV3Prompt } from '@ai-sdk/provider';
22

3-
import { describe, expect, it, vi } from 'vitest';
3+
import { beforeEach, describe, expect, it, vi } from 'vitest';
44
import { OpenRouterChatLanguageModel } from '../../chat/openrouter-chat-language-model.js';
55

66
const createTestSettings = () => ({
@@ -9,37 +9,52 @@ const createTestSettings = () => ({
99
userAgent: 'test-user-agent/0.0.0',
1010
});
1111

12+
// Track OpenRouter constructor calls
13+
const openRouterConstructorCalls: unknown[][] = [];
14+
1215
// Mock the OpenRouter SDK
1316
vi.mock('@openrouter/sdk', () => {
1417
return {
15-
OpenRouter: vi.fn().mockImplementation(() => ({
16-
beta: {
17-
responses: {
18-
send: vi.fn().mockResolvedValue({
19-
id: 'resp-test',
20-
model: 'test-model',
21-
status: 'completed',
22-
createdAt: 1704067200,
23-
output: [
24-
{
25-
type: 'message',
26-
content: [{ type: 'output_text', text: 'Hello' }],
18+
OpenRouter: vi.fn().mockImplementation((...args: unknown[]) => {
19+
openRouterConstructorCalls.push(args);
20+
return {
21+
beta: {
22+
responses: {
23+
send: vi.fn().mockResolvedValue({
24+
id: 'resp-test',
25+
model: 'test-model',
26+
status: 'completed',
27+
createdAt: 1704067200,
28+
output: [
29+
{
30+
type: 'message',
31+
content: [{ type: 'output_text', text: 'Hello' }],
32+
},
33+
],
34+
outputText: 'Hello',
35+
usage: {
36+
inputTokens: 10,
37+
outputTokens: 5,
38+
totalTokens: 15,
2739
},
28-
],
29-
outputText: 'Hello',
30-
usage: {
31-
inputTokens: 10,
32-
outputTokens: 5,
33-
totalTokens: 15,
34-
},
35-
}),
40+
}),
41+
},
3642
},
37-
},
38-
})),
43+
};
44+
}),
3945
SDK_METADATA: { userAgent: 'test-sdk/1.0.0' },
4046
};
4147
});
4248

49+
// Mock HTTPClient
50+
vi.mock('@openrouter/sdk/lib/http', () => {
51+
return {
52+
HTTPClient: vi.fn().mockImplementation((options: unknown) => ({
53+
_options: options,
54+
})),
55+
};
56+
});
57+
4358
const createTestPrompt = (): LanguageModelV3Prompt => [
4459
{
4560
role: 'user',
@@ -48,6 +63,10 @@ const createTestPrompt = (): LanguageModelV3Prompt => [
4863
];
4964

5065
describe('OpenRouterChatLanguageModel', () => {
66+
beforeEach(() => {
67+
// Clear constructor call tracking before each test
68+
openRouterConstructorCalls.length = 0;
69+
});
5170
describe('constructor', () => {
5271
it('should set specificationVersion to v3', () => {
5372
const model = new OpenRouterChatLanguageModel(
@@ -399,4 +418,127 @@ describe('OpenRouterChatLanguageModel', () => {
399418
expect(body.route).toEqual('fallback');
400419
});
401420
});
421+
422+
describe('extraBody forwarding', () => {
423+
it('should spread extraBody into doGenerate request params', async () => {
424+
const model = new OpenRouterChatLanguageModel('openai/gpt-4o', {
425+
...createTestSettings(),
426+
extraBody: {
427+
customField: 'customValue',
428+
anotherField: 123,
429+
},
430+
});
431+
432+
const result = await model.doGenerate({
433+
prompt: createTestPrompt(),
434+
});
435+
436+
// Verify request body includes extraBody fields
437+
const body = result.request?.body as Record<string, unknown>;
438+
expect(body).toBeDefined();
439+
expect(body).toHaveProperty('customField', 'customValue');
440+
expect(body).toHaveProperty('anotherField', 123);
441+
});
442+
443+
it('should spread extraBody into doStream request params', async () => {
444+
const model = new OpenRouterChatLanguageModel('openai/gpt-4o', {
445+
...createTestSettings(),
446+
extraBody: {
447+
customField: 'streamValue',
448+
},
449+
});
450+
451+
const result = await model.doStream({
452+
prompt: createTestPrompt(),
453+
});
454+
455+
// Verify request body includes extraBody fields
456+
const body = result.request?.body as Record<string, unknown>;
457+
expect(body).toBeDefined();
458+
expect(body).toHaveProperty('customField', 'streamValue');
459+
});
460+
461+
it('should allow explicit params to override extraBody', async () => {
462+
const model = new OpenRouterChatLanguageModel('openai/gpt-4o', {
463+
...createTestSettings(),
464+
extraBody: {
465+
model: 'should-be-overridden',
466+
temperature: 0.5,
467+
},
468+
});
469+
470+
const result = await model.doGenerate({
471+
prompt: createTestPrompt(),
472+
temperature: 0.9,
473+
});
474+
475+
// Verify explicit params override extraBody
476+
const body = result.request?.body as Record<string, unknown>;
477+
expect(body).toBeDefined();
478+
// Model should be from the constructor, not extraBody
479+
expect(body.model).toBe('openai/gpt-4o');
480+
// Temperature from call options should override extraBody
481+
expect(body.temperature).toBe(0.9);
482+
});
483+
});
484+
485+
describe('custom fetch forwarding', () => {
486+
it('should pass custom fetch to OpenRouter client via HTTPClient in doGenerate', async () => {
487+
const customFetch = vi.fn();
488+
const model = new OpenRouterChatLanguageModel('openai/gpt-4o', {
489+
...createTestSettings(),
490+
fetch: customFetch,
491+
});
492+
493+
await model.doGenerate({
494+
prompt: createTestPrompt(),
495+
});
496+
497+
// Verify OpenRouter constructor was called with httpClient
498+
expect(openRouterConstructorCalls.length).toBeGreaterThan(0);
499+
const lastCall = openRouterConstructorCalls[
500+
openRouterConstructorCalls.length - 1
501+
] as [{ httpClient?: unknown }];
502+
expect(lastCall[0]).toHaveProperty('httpClient');
503+
expect(lastCall[0].httpClient).toBeDefined();
504+
});
505+
506+
it('should pass custom fetch to OpenRouter client via HTTPClient in doStream', async () => {
507+
const customFetch = vi.fn();
508+
const model = new OpenRouterChatLanguageModel('openai/gpt-4o', {
509+
...createTestSettings(),
510+
fetch: customFetch,
511+
});
512+
513+
await model.doStream({
514+
prompt: createTestPrompt(),
515+
});
516+
517+
// Verify OpenRouter constructor was called with httpClient
518+
expect(openRouterConstructorCalls.length).toBeGreaterThan(0);
519+
const lastCall = openRouterConstructorCalls[
520+
openRouterConstructorCalls.length - 1
521+
] as [{ httpClient?: unknown }];
522+
expect(lastCall[0]).toHaveProperty('httpClient');
523+
expect(lastCall[0].httpClient).toBeDefined();
524+
});
525+
526+
it('should not pass httpClient when no custom fetch is provided', async () => {
527+
const model = new OpenRouterChatLanguageModel('openai/gpt-4o', {
528+
...createTestSettings(),
529+
// No custom fetch
530+
});
531+
532+
await model.doGenerate({
533+
prompt: createTestPrompt(),
534+
});
535+
536+
// Verify OpenRouter constructor was called without httpClient (or with undefined)
537+
expect(openRouterConstructorCalls.length).toBeGreaterThan(0);
538+
const lastCall = openRouterConstructorCalls[
539+
openRouterConstructorCalls.length - 1
540+
] as [{ httpClient?: unknown }];
541+
expect(lastCall[0].httpClient).toBeUndefined();
542+
});
543+
});
402544
});

src/chat/openrouter-chat-language-model.ts

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type { ReasoningOutputItem } from './extract-reasoning-details.js';
2323

2424
import { combineHeaders, normalizeHeaders } from '@ai-sdk/provider-utils';
2525
import { OpenRouter } from '@openrouter/sdk';
26+
import { HTTPClient } from '@openrouter/sdk/lib/http';
2627
import { buildProviderMetadata } from '../utils/build-provider-metadata.js';
2728
import { buildUsage } from '../utils/build-usage.js';
2829
import { parseOpenRouterOptions } from '../utils/parse-provider-options.js';
@@ -67,6 +68,9 @@ function buildModelOptionsParams(
6768
...(mergedOptions.route !== undefined && {
6869
route: mergedOptions.route,
6970
}),
71+
// Note: usage.include is not supported by the Responses API.
72+
// The Responses API always returns usage information in the response.
73+
// If needed, usage options can be passed via extraBody for Chat Completions API.
7074
};
7175
}
7276

@@ -101,11 +105,15 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
101105
): Promise<LanguageModelV3GenerateResult> {
102106
const warnings: SharedV3Warning[] = [];
103107

104-
// Create OpenRouter client
108+
// Create OpenRouter client with optional custom fetch
109+
const httpClient = this.settings.fetch
110+
? new HTTPClient({ fetcher: this.settings.fetch })
111+
: undefined;
105112
const client = new OpenRouter({
106113
apiKey: this.settings.apiKey,
107114
serverURL: this.settings.baseURL,
108115
userAgent: this.settings.userAgent,
116+
httpClient,
109117
});
110118

111119
// Convert messages to OpenRouter Responses API format
@@ -127,7 +135,9 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
127135
);
128136

129137
// Build request parameters for Responses API (non-streaming)
138+
// Note: extraBody is spread first so explicit params can override
130139
const requestParams: OpenResponsesRequest & { stream: false } = {
140+
...this.settings.extraBody,
131141
model: this.modelId,
132142
input: openRouterInput as OpenResponsesRequest['input'],
133143
stream: false,
@@ -310,11 +320,15 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
310320
): Promise<LanguageModelV3StreamResult> {
311321
const warnings: SharedV3Warning[] = [];
312322

313-
// Create OpenRouter client
323+
// Create OpenRouter client with optional custom fetch
324+
const httpClient = this.settings.fetch
325+
? new HTTPClient({ fetcher: this.settings.fetch })
326+
: undefined;
314327
const client = new OpenRouter({
315328
apiKey: this.settings.apiKey,
316329
serverURL: this.settings.baseURL,
317330
userAgent: this.settings.userAgent,
331+
httpClient,
318332
});
319333

320334
// Convert messages to OpenRouter Responses API format
@@ -336,7 +350,9 @@ export class OpenRouterChatLanguageModel implements LanguageModelV3 {
336350
);
337351

338352
// Build request parameters for Responses API (streaming)
353+
// Note: extraBody is spread first so explicit params can override
339354
const requestParams: OpenResponsesRequest & { stream: true } = {
355+
...this.settings.extraBody,
340356
model: this.modelId,
341357
input: openRouterInput as OpenResponsesRequest['input'],
342358
stream: true,

src/embedding/openrouter-embedding-model.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import type { OpenRouterModelSettings } from '../openrouter-provider.js';
99

1010
import { combineHeaders, normalizeHeaders } from '@ai-sdk/provider-utils';
1111
import { OpenRouter } from '@openrouter/sdk';
12+
import { HTTPClient } from '@openrouter/sdk/lib/http';
1213

1314
/**
1415
* OpenRouter embedding model implementing AI SDK V3 EmbeddingModelV3 interface.
@@ -41,14 +42,19 @@ export class OpenRouterEmbeddingModel implements EmbeddingModelV3 {
4142
): Promise<EmbeddingModelV3Result> {
4243
const warnings: SharedV3Warning[] = [];
4344

44-
// Create OpenRouter client
45+
// Create OpenRouter client with optional custom fetch
46+
const httpClient = this.settings.fetch
47+
? new HTTPClient({ fetcher: this.settings.fetch })
48+
: undefined;
4549
const client = new OpenRouter({
4650
apiKey: this.settings.apiKey,
4751
serverURL: this.settings.baseURL,
4852
userAgent: this.settings.userAgent,
53+
httpClient,
4954
});
5055

5156
// Build request with provider routing options if configured
57+
// Note: extraBody is spread first so explicit params can override
5258
const requestParams: {
5359
model: string;
5460
input: string[];
@@ -58,7 +64,9 @@ export class OpenRouterEmbeddingModel implements EmbeddingModelV3 {
5864
allowFallbacks?: boolean;
5965
requireParameters?: boolean;
6066
};
67+
[key: string]: unknown;
6168
} = {
69+
...this.settings.extraBody,
6270
model: this.modelId,
6371
input: options.values,
6472
};

0 commit comments

Comments
 (0)