diff --git a/.changeset/embedding-provider-options.md b/.changeset/embedding-provider-options.md new file mode 100644 index 00000000..90102a4c --- /dev/null +++ b/.changeset/embedding-provider-options.md @@ -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). diff --git a/README.md b/README.md index 5c655870..e32d4144 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/e2e/issues/issue-616-embedding-provider-options.test.ts b/e2e/issues/issue-616-embedding-provider-options.test.ts new file mode 100644 index 00000000..3f3726ba --- /dev/null +++ b/e2e/issues/issue-616-embedding-provider-options.test.ts @@ -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); + }); +}); diff --git a/src/embedding/index.test.ts b/src/embedding/index.test.ts index 974e8dd8..3b7d3f36 100644 --- a/src/embedding/index.test.ts +++ b/src/embedding/index.test.ts @@ -1,3 +1,4 @@ +import { embed, embedMany } from 'ai'; import { describe, expect, it } from 'vitest'; import { createOpenRouter } from '../provider'; import { OpenRouterEmbeddingModel } from './index'; @@ -259,4 +260,207 @@ describe('OpenRouterEmbeddingModel', () => { }); }); }); + + describe('providerOptions.openrouter', () => { + const createCapturingProvider = ( + options: { extraBody?: Record } = {}, + ) => { + const bodies: Array> = []; + 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('does not let runtime options override model or input', async () => { + const { provider, bodies } = createCapturingProvider(); + const model = provider.textEmbeddingModel( + 'openai/text-embedding-3-small', + ); + const providerOptions = { + openrouter: { model: 'other/model', input: ['different text'] }, + }; + + await embed({ model, value: 'invoice', providerOptions }); + await embedMany({ model, values: ['a', 'b'], providerOptions }); + + expect(bodies[0]).toMatchObject({ + model: 'openai/text-embedding-3-small', + input: ['invoice'], + }); + expect(bodies[1]).toMatchObject({ + model: 'openai/text-embedding-3-small', + input: ['a', 'b'], + }); + }); + + 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'); + }); + }); }); diff --git a/src/embedding/index.ts b/src/embedding/index.ts index a9fb34bf..3325700a 100644 --- a/src/embedding/index.ts +++ b/src/embedding/index.ts @@ -1,7 +1,7 @@ import type { EmbeddingModelV4, - SharedV4Headers, - SharedV4ProviderMetadata, + EmbeddingModelV4CallOptions, + EmbeddingModelV4Result, } from '@ai-sdk/provider'; import type { OpenRouterEmbeddingModelId, @@ -25,6 +25,23 @@ type OpenRouterEmbeddingConfig = { extraBody?: Record; }; +/** + * 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 | undefined { + const objects = sources.filter( + (source): source is Record => + 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'; @@ -45,29 +62,39 @@ export class OpenRouterEmbeddingModel implements EmbeddingModelV4 { this.config = config; } - async doEmbed(options: { - values: Array; - abortSignal?: AbortSignal; - headers?: Record; - }): Promise<{ - embeddings: Array>; - usage?: { tokens: number }; - providerMetadata?: SharedV4ProviderMetadata; - response?: { - headers?: SharedV4Headers; - body?: unknown; - }; - warnings: Array; - }> { + async doEmbed( + options: EmbeddingModelV4CallOptions, + ): Promise { const { values, abortSignal, headers } = options; + // Call-time options (`providerOptions.openrouter`) are passed through to + // the request body, mirroring the chat and completion models. `model` and + // `input` are dropped so call-time options cannot change which model is + // used or which values are embedded. + const { + provider: runtimeProvider, + model: _ignoredModel, + input: _ignoredInput, + ...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, + provider: providerRouting, }; const { value: responseValue, responseHeaders } = await postJsonToApi({