Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/embedding-provider-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@openrouter/ai-sdk-provider': patch
---

Forward call-time `providerOptions.openrouter` in the embedding model (`embed` / `embedMany`). Options such as `provider`, `user`, `dimensions` and `input_type` were previously ignored. Provider routing preferences are now shallow merged from model settings, `extraBody` and `providerOptions` (later wins) instead of `extraBody.provider` replacing `settings.provider` (related to #616).
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,30 @@ const { embeddings } = await embedMany({
console.log(embeddings); // Array of embedding arrays
```

### Embedding Provider Options

Pass OpenRouter request options per call with `providerOptions.openrouter`. They work with both `embed` and `embedMany` and are sent in the `/embeddings` request body (for example `provider`, `user`, `dimensions`, `input_type`):

```ts
import { embed } from 'ai';
import { openrouter } from '@openrouter/ai-sdk-provider';

const { embedding } = await embed({
model: openrouter.textEmbeddingModel('openai/text-embedding-3-small', {
provider: { order: ['openai'] },
}),
value: 'sunny day at the beach',
providerOptions: {
openrouter: {
provider: { allow_fallbacks: false },
dimensions: 256,
},
},
});
```

Call-time options take precedence over model settings and `extraBody`. Provider routing preferences (`provider`) are shallow merged from model settings, `extraBody` and `providerOptions` instead of replacing each other, so the request above routes with `{ order: ['openai'], allow_fallbacks: false }`.

### Supported Embedding Models

OpenRouter supports various embedding models including:
Expand Down
82 changes: 82 additions & 0 deletions e2e/issues/issue-616-embedding-provider-options.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Regression test for GitHub issue #616
* https://github.com/OpenRouterTeam/ai-sdk-provider/issues/616
*
* Issue: `providerOptions.openrouter.provider` routing preferences were
* reported as not applied. Chat and completion were verified to forward them;
* the embedding model (`embed` / `embedMany`) ignored `providerOptions`
* entirely.
*/
import { embed, embedMany } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { createOpenRouter } from '@/src';

vi.setConfig({
testTimeout: 60_000,
});

describe('Issue #616: embedding models honor providerOptions.openrouter', () => {
const openrouter = createOpenRouter({
apiKey: process.env.OPENROUTER_API_KEY,
baseUrl: `${process.env.OPENROUTER_API_BASE ?? 'https://openrouter.ai'}/api/v1`,
});
const model = openrouter.textEmbeddingModel('openai/text-embedding-3-small');

it('embed: applies runtime provider.only (unknown provider is rejected)', async () => {
await expect(
embed({
model,
value: 'sunny day at the beach',
providerOptions: {
openrouter: {
provider: {
only: ['nonexistent-provider'],
allow_fallbacks: false,
},
},
},
}),
).rejects.toThrow();
});

it('embedMany: applies runtime provider.only (unknown provider is rejected)', async () => {
await expect(
embedMany({
model,
values: ['sunny day at the beach', 'rainy afternoon'],
providerOptions: {
openrouter: {
provider: {
only: ['nonexistent-provider'],
allow_fallbacks: false,
},
},
},
}),
).rejects.toThrow();
});

it('embed: succeeds with a valid runtime provider routing preference', async () => {
const { embedding } = await embed({
model,
value: 'sunny day at the beach',
providerOptions: {
openrouter: { provider: { only: ['openai'] } },
},
});

expect(embedding.length).toBeGreaterThan(0);
});

it('embed: forwards dimensions from providerOptions', async () => {
const { embedding } = await embed({
model,
value: 'sunny day at the beach',
providerOptions: {
openrouter: { dimensions: 256 },
},
});

expect(embedding).toHaveLength(256);
});
});
182 changes: 182 additions & 0 deletions src/embedding/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { embed, embedMany } from 'ai';
import { describe, expect, it } from 'vitest';
import { createOpenRouter } from '../provider';
import { OpenRouterEmbeddingModel } from './index';
Expand Down Expand Up @@ -259,4 +260,185 @@ describe('OpenRouterEmbeddingModel', () => {
});
});
});

describe('providerOptions.openrouter', () => {
const createCapturingProvider = (
options: { extraBody?: Record<string, unknown> } = {},
) => {
const bodies: Array<Record<string, unknown>> = [];
const provider = createOpenRouter({
apiKey: 'test-key',
...options,
fetch: async (_url, init) => {
const body = JSON.parse(String(init?.body));
bodies.push(body);
const inputs: unknown[] = Array.isArray(body.input) ? body.input : [];
return new Response(
JSON.stringify({
object: 'list',
data: inputs.map((_, index) => ({
object: 'embedding',
embedding: [0.1, 0.2],
index,
})),
model: 'openai/text-embedding-3-small',
usage: { prompt_tokens: 5, total_tokens: 5 },
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
);
},
});
return { provider, bodies };
};

it('embed: forwards runtime-only provider routing', async () => {
const { provider, bodies } = createCapturingProvider();

await embed({
model: provider.textEmbeddingModel('openai/text-embedding-3-small'),
value: 'hello',
providerOptions: { openrouter: { provider: { only: ['openai'] } } },
});

expect(bodies).toHaveLength(1);
expect(bodies[0]?.provider).toEqual({ only: ['openai'] });
});

it('embedMany: forwards runtime-only provider routing', async () => {
const { provider, bodies } = createCapturingProvider();

await embedMany({
model: provider.textEmbeddingModel('openai/text-embedding-3-small'),
values: ['a', 'b'],
providerOptions: { openrouter: { provider: { only: ['openai'] } } },
});

expect(bodies).toHaveLength(1);
expect(bodies[0]?.input).toEqual(['a', 'b']);
expect(bodies[0]?.provider).toEqual({ only: ['openai'] });
});

it('shallow merges runtime provider with settings.provider', async () => {
const { provider, bodies } = createCapturingProvider();

await embed({
model: provider.textEmbeddingModel('openai/text-embedding-3-small', {
provider: { order: ['openai'], allow_fallbacks: false },
}),
value: 'hello',
providerOptions: {
openrouter: { provider: { allow_fallbacks: true, sort: 'price' } },
},
});

expect(bodies[0]?.provider).toEqual({
order: ['openai'],
allow_fallbacks: true,
sort: 'price',
});
});

it('applies precedence settings < config.extraBody < settings.extraBody < runtime', async () => {
const { provider, bodies } = createCapturingProvider({
extraBody: {
provider: { order: ['config'], sort: 'latency', ignore: ['x'] },
},
});

await embedMany({
model: provider.textEmbeddingModel('openai/text-embedding-3-small', {
provider: { order: ['settings'], data_collection: 'deny' },
extraBody: { provider: { sort: 'throughput', only: ['extra'] } },
}),
values: ['a'],
providerOptions: { openrouter: { provider: { only: ['runtime'] } } },
});

expect(bodies[0]?.provider).toEqual({
order: ['config'],
data_collection: 'deny',
sort: 'throughput',
ignore: ['x'],
only: ['runtime'],
});
});

it('ignores non-object runtime provider values', async () => {
const { provider, bodies } = createCapturingProvider();

await embed({
model: provider.textEmbeddingModel('openai/text-embedding-3-small', {
provider: { order: ['openai'] },
}),
value: 'hello',
providerOptions: { openrouter: { provider: null } },
});

expect(bodies[0]?.provider).toEqual({ order: ['openai'] });
});

it('passes through other runtime options, overriding settings and extraBody', async () => {
const { provider, bodies } = createCapturingProvider({
extraBody: { dimensions: 128 },
});

await embed({
model: provider.textEmbeddingModel('openai/text-embedding-3-small', {
user: 'settings-user',
extraBody: { input_type: 'search_document' },
}),
value: 'hello',
providerOptions: {
openrouter: {
user: 'runtime-user',
dimensions: 256,
input_type: 'search_query',
session_id: 'session-1',
},
},
});

expect(bodies[0]).toMatchObject({
user: 'runtime-user',
dimensions: 256,
input_type: 'search_query',
session_id: 'session-1',
});
});

it('leaves the request body unchanged without providerOptions', async () => {
const { provider, bodies } = createCapturingProvider();

await embedMany({
model: provider.textEmbeddingModel('openai/text-embedding-3-small', {
user: 'test-user',
provider: { order: ['openai'] },
}),
values: ['a', 'b'],
});

expect(bodies[0]).toEqual({
model: 'openai/text-embedding-3-small',
input: ['a', 'b'],
user: 'test-user',
provider: { order: ['openai'] },
});
});

it('omits provider when no source sets it', async () => {
const { provider, bodies } = createCapturingProvider();

await embed({
model: provider.textEmbeddingModel('openai/text-embedding-3-small'),
value: 'hello',
providerOptions: { openrouter: { user: 'u' } },
});

expect(bodies[0]).not.toHaveProperty('provider');
expect(bodies[0]?.user).toBe('u');
});
});
});
55 changes: 38 additions & 17 deletions src/embedding/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type {
EmbeddingModelV4,
SharedV4Headers,
SharedV4ProviderMetadata,
EmbeddingModelV4CallOptions,
EmbeddingModelV4Result,
} from '@ai-sdk/provider';
import type {
OpenRouterEmbeddingModelId,
Expand All @@ -25,6 +25,23 @@ type OpenRouterEmbeddingConfig = {
extraBody?: Record<string, unknown>;
};

/**
* Shallow merges provider routing preferences in ascending precedence order.
* Non-object sources (`undefined`, `null`, arrays, primitives) are ignored.
*
* TODO(#628): replace with the shared `mergeProviderRouting` helper from
* `src/utils/merge-provider-routing.ts` once that PR is merged.
*/
function mergeProviderRouting(
...sources: unknown[]
): Record<string, unknown> | undefined {
const objects = sources.filter(
(source): source is Record<string, unknown> =>
typeof source === 'object' && source !== null && !Array.isArray(source),
);
return objects.length > 0 ? Object.assign({}, ...objects) : undefined;
}

export class OpenRouterEmbeddingModel implements EmbeddingModelV4 {
readonly specificationVersion = 'v4' as const;
readonly provider = 'openrouter';
Expand All @@ -45,29 +62,33 @@ export class OpenRouterEmbeddingModel implements EmbeddingModelV4 {
this.config = config;
}

async doEmbed(options: {
values: Array<string>;
abortSignal?: AbortSignal;
headers?: Record<string, string | undefined>;
}): Promise<{
embeddings: Array<Array<number>>;
usage?: { tokens: number };
providerMetadata?: SharedV4ProviderMetadata;
response?: {
headers?: SharedV4Headers;
body?: unknown;
};
warnings: Array<import('@ai-sdk/provider').SharedV4Warning>;
}> {
async doEmbed(
options: EmbeddingModelV4CallOptions,
): Promise<EmbeddingModelV4Result> {
const { values, abortSignal, headers } = options;

// Call-time options (`providerOptions.openrouter`) are passed through to
// the request body, mirroring the chat and completion models.
const { provider: runtimeProvider, ...runtimeOptions } =
options.providerOptions?.openrouter ?? {};

// Provider routing preferences are shallow merged rather than replaced,
// with the same precedence as the rest of the body (later wins).
const providerRouting = mergeProviderRouting(
this.settings.provider,
this.config.extraBody?.provider,
this.settings.extraBody?.provider,
runtimeProvider,
);

const args = {
model: this.modelId,
input: values,
user: this.settings.user,
provider: this.settings.provider,
...this.config.extraBody,
...this.settings.extraBody,
...runtimeOptions,
Comment thread
mattapperson marked this conversation as resolved.
Resolved
provider: providerRouting,
};

const { value: responseValue, responseHeaders } = await postJsonToApi({
Expand Down
Loading