@ai-sdk/openai-compatible 2.0.15 → 2.0.17

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.
Files changed (39) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/index.d.mts +5 -0
  3. package/dist/index.d.ts +5 -0
  4. package/dist/index.js +23 -6
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +23 -6
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +3 -2
  9. package/src/chat/convert-openai-compatible-chat-usage.ts +55 -0
  10. package/src/chat/convert-to-openai-compatible-chat-messages.test.ts +1238 -0
  11. package/src/chat/convert-to-openai-compatible-chat-messages.ts +246 -0
  12. package/src/chat/get-response-metadata.ts +15 -0
  13. package/src/chat/map-openai-compatible-finish-reason.ts +19 -0
  14. package/src/chat/openai-compatible-api-types.ts +86 -0
  15. package/src/chat/openai-compatible-chat-language-model.test.ts +3292 -0
  16. package/src/chat/openai-compatible-chat-language-model.ts +830 -0
  17. package/src/chat/openai-compatible-chat-options.ts +34 -0
  18. package/src/chat/openai-compatible-metadata-extractor.ts +48 -0
  19. package/src/chat/openai-compatible-prepare-tools.test.ts +336 -0
  20. package/src/chat/openai-compatible-prepare-tools.ts +98 -0
  21. package/src/completion/convert-openai-compatible-completion-usage.ts +46 -0
  22. package/src/completion/convert-to-openai-compatible-completion-prompt.ts +93 -0
  23. package/src/completion/get-response-metadata.ts +15 -0
  24. package/src/completion/map-openai-compatible-finish-reason.ts +19 -0
  25. package/src/completion/openai-compatible-completion-language-model.test.ts +773 -0
  26. package/src/completion/openai-compatible-completion-language-model.ts +390 -0
  27. package/src/completion/openai-compatible-completion-options.ts +33 -0
  28. package/src/embedding/openai-compatible-embedding-model.test.ts +171 -0
  29. package/src/embedding/openai-compatible-embedding-model.ts +166 -0
  30. package/src/embedding/openai-compatible-embedding-options.ts +21 -0
  31. package/src/image/openai-compatible-image-model.test.ts +494 -0
  32. package/src/image/openai-compatible-image-model.ts +205 -0
  33. package/src/image/openai-compatible-image-settings.ts +1 -0
  34. package/src/index.ts +27 -0
  35. package/src/internal/index.ts +4 -0
  36. package/src/openai-compatible-error.ts +30 -0
  37. package/src/openai-compatible-provider.test.ts +329 -0
  38. package/src/openai-compatible-provider.ts +189 -0
  39. package/src/version.ts +5 -0
@@ -0,0 +1,390 @@
1
+ import {
2
+ APICallError,
3
+ LanguageModelV3,
4
+ LanguageModelV3CallOptions,
5
+ LanguageModelV3Content,
6
+ LanguageModelV3FinishReason,
7
+ LanguageModelV3GenerateResult,
8
+ LanguageModelV3StreamPart,
9
+ LanguageModelV3StreamResult,
10
+ SharedV3Warning,
11
+ } from '@ai-sdk/provider';
12
+ import {
13
+ combineHeaders,
14
+ createEventSourceResponseHandler,
15
+ createJsonErrorResponseHandler,
16
+ createJsonResponseHandler,
17
+ FetchFunction,
18
+ parseProviderOptions,
19
+ ParseResult,
20
+ postJsonToApi,
21
+ ResponseHandler,
22
+ } from '@ai-sdk/provider-utils';
23
+ import { z } from 'zod/v4';
24
+ import {
25
+ defaultOpenAICompatibleErrorStructure,
26
+ ProviderErrorStructure,
27
+ } from '../openai-compatible-error';
28
+ import { convertOpenAICompatibleCompletionUsage } from './convert-openai-compatible-completion-usage';
29
+ import { convertToOpenAICompatibleCompletionPrompt } from './convert-to-openai-compatible-completion-prompt';
30
+ import { getResponseMetadata } from './get-response-metadata';
31
+ import { mapOpenAICompatibleFinishReason } from './map-openai-compatible-finish-reason';
32
+ import {
33
+ OpenAICompatibleCompletionModelId,
34
+ openaiCompatibleCompletionProviderOptions,
35
+ } from './openai-compatible-completion-options';
36
+
37
+ type OpenAICompatibleCompletionConfig = {
38
+ provider: string;
39
+ includeUsage?: boolean;
40
+ headers: () => Record<string, string | undefined>;
41
+ url: (options: { modelId: string; path: string }) => string;
42
+ fetch?: FetchFunction;
43
+ errorStructure?: ProviderErrorStructure<any>;
44
+
45
+ /**
46
+ * The supported URLs for the model.
47
+ */
48
+ supportedUrls?: () => LanguageModelV3['supportedUrls'];
49
+ };
50
+
51
+ export class OpenAICompatibleCompletionLanguageModel
52
+ implements LanguageModelV3
53
+ {
54
+ readonly specificationVersion = 'v3';
55
+
56
+ readonly modelId: OpenAICompatibleCompletionModelId;
57
+ private readonly config: OpenAICompatibleCompletionConfig;
58
+ private readonly failedResponseHandler: ResponseHandler<APICallError>;
59
+ private readonly chunkSchema; // type inferred via constructor
60
+
61
+ constructor(
62
+ modelId: OpenAICompatibleCompletionModelId,
63
+ config: OpenAICompatibleCompletionConfig,
64
+ ) {
65
+ this.modelId = modelId;
66
+ this.config = config;
67
+
68
+ // initialize error handling:
69
+ const errorStructure =
70
+ config.errorStructure ?? defaultOpenAICompatibleErrorStructure;
71
+ this.chunkSchema = createOpenAICompatibleCompletionChunkSchema(
72
+ errorStructure.errorSchema,
73
+ );
74
+ this.failedResponseHandler = createJsonErrorResponseHandler(errorStructure);
75
+ }
76
+
77
+ get provider(): string {
78
+ return this.config.provider;
79
+ }
80
+
81
+ private get providerOptionsName(): string {
82
+ return this.config.provider.split('.')[0].trim();
83
+ }
84
+
85
+ get supportedUrls() {
86
+ return this.config.supportedUrls?.() ?? {};
87
+ }
88
+
89
+ private async getArgs({
90
+ prompt,
91
+ maxOutputTokens,
92
+ temperature,
93
+ topP,
94
+ topK,
95
+ frequencyPenalty,
96
+ presencePenalty,
97
+ stopSequences: userStopSequences,
98
+ responseFormat,
99
+ seed,
100
+ providerOptions,
101
+ tools,
102
+ toolChoice,
103
+ }: LanguageModelV3CallOptions) {
104
+ const warnings: SharedV3Warning[] = [];
105
+
106
+ // Parse provider options
107
+ const completionOptions =
108
+ (await parseProviderOptions({
109
+ provider: this.providerOptionsName,
110
+ providerOptions,
111
+ schema: openaiCompatibleCompletionProviderOptions,
112
+ })) ?? {};
113
+
114
+ if (topK != null) {
115
+ warnings.push({ type: 'unsupported', feature: 'topK' });
116
+ }
117
+
118
+ if (tools?.length) {
119
+ warnings.push({ type: 'unsupported', feature: 'tools' });
120
+ }
121
+
122
+ if (toolChoice != null) {
123
+ warnings.push({ type: 'unsupported', feature: 'toolChoice' });
124
+ }
125
+
126
+ if (responseFormat != null && responseFormat.type !== 'text') {
127
+ warnings.push({
128
+ type: 'unsupported',
129
+ feature: 'responseFormat',
130
+ details: 'JSON response format is not supported.',
131
+ });
132
+ }
133
+
134
+ const { prompt: completionPrompt, stopSequences } =
135
+ convertToOpenAICompatibleCompletionPrompt({ prompt });
136
+
137
+ const stop = [...(stopSequences ?? []), ...(userStopSequences ?? [])];
138
+
139
+ return {
140
+ args: {
141
+ // model id:
142
+ model: this.modelId,
143
+
144
+ // model specific settings:
145
+ echo: completionOptions.echo,
146
+ logit_bias: completionOptions.logitBias,
147
+ suffix: completionOptions.suffix,
148
+ user: completionOptions.user,
149
+
150
+ // standardized settings:
151
+ max_tokens: maxOutputTokens,
152
+ temperature,
153
+ top_p: topP,
154
+ frequency_penalty: frequencyPenalty,
155
+ presence_penalty: presencePenalty,
156
+ seed,
157
+ ...providerOptions?.[this.providerOptionsName],
158
+
159
+ // prompt:
160
+ prompt: completionPrompt,
161
+
162
+ // stop sequences:
163
+ stop: stop.length > 0 ? stop : undefined,
164
+ },
165
+ warnings,
166
+ };
167
+ }
168
+
169
+ async doGenerate(
170
+ options: LanguageModelV3CallOptions,
171
+ ): Promise<LanguageModelV3GenerateResult> {
172
+ const { args, warnings } = await this.getArgs(options);
173
+
174
+ const {
175
+ responseHeaders,
176
+ value: response,
177
+ rawValue: rawResponse,
178
+ } = await postJsonToApi({
179
+ url: this.config.url({
180
+ path: '/completions',
181
+ modelId: this.modelId,
182
+ }),
183
+ headers: combineHeaders(this.config.headers(), options.headers),
184
+ body: args,
185
+ failedResponseHandler: this.failedResponseHandler,
186
+ successfulResponseHandler: createJsonResponseHandler(
187
+ openaiCompatibleCompletionResponseSchema,
188
+ ),
189
+ abortSignal: options.abortSignal,
190
+ fetch: this.config.fetch,
191
+ });
192
+
193
+ const choice = response.choices[0];
194
+ const content: Array<LanguageModelV3Content> = [];
195
+
196
+ // text content:
197
+ if (choice.text != null && choice.text.length > 0) {
198
+ content.push({ type: 'text', text: choice.text });
199
+ }
200
+
201
+ return {
202
+ content,
203
+ usage: convertOpenAICompatibleCompletionUsage(response.usage),
204
+ finishReason: {
205
+ unified: mapOpenAICompatibleFinishReason(choice.finish_reason),
206
+ raw: choice.finish_reason,
207
+ },
208
+ request: { body: args },
209
+ response: {
210
+ ...getResponseMetadata(response),
211
+ headers: responseHeaders,
212
+ body: rawResponse,
213
+ },
214
+ warnings,
215
+ };
216
+ }
217
+
218
+ async doStream(
219
+ options: LanguageModelV3CallOptions,
220
+ ): Promise<LanguageModelV3StreamResult> {
221
+ const { args, warnings } = await this.getArgs(options);
222
+
223
+ const body = {
224
+ ...args,
225
+ stream: true,
226
+
227
+ // only include stream_options when in strict compatibility mode:
228
+ stream_options: this.config.includeUsage
229
+ ? { include_usage: true }
230
+ : undefined,
231
+ };
232
+
233
+ const { responseHeaders, value: response } = await postJsonToApi({
234
+ url: this.config.url({
235
+ path: '/completions',
236
+ modelId: this.modelId,
237
+ }),
238
+ headers: combineHeaders(this.config.headers(), options.headers),
239
+ body,
240
+ failedResponseHandler: this.failedResponseHandler,
241
+ successfulResponseHandler: createEventSourceResponseHandler(
242
+ this.chunkSchema,
243
+ ),
244
+ abortSignal: options.abortSignal,
245
+ fetch: this.config.fetch,
246
+ });
247
+
248
+ let finishReason: LanguageModelV3FinishReason = {
249
+ unified: 'other',
250
+ raw: undefined,
251
+ };
252
+ let usage:
253
+ | {
254
+ prompt_tokens: number | undefined;
255
+ completion_tokens: number | undefined;
256
+ total_tokens: number | undefined;
257
+ }
258
+ | undefined = undefined;
259
+ let isFirstChunk = true;
260
+
261
+ return {
262
+ stream: response.pipeThrough(
263
+ new TransformStream<
264
+ ParseResult<z.infer<typeof this.chunkSchema>>,
265
+ LanguageModelV3StreamPart
266
+ >({
267
+ start(controller) {
268
+ controller.enqueue({ type: 'stream-start', warnings });
269
+ },
270
+
271
+ transform(chunk, controller) {
272
+ if (options.includeRawChunks) {
273
+ controller.enqueue({ type: 'raw', rawValue: chunk.rawValue });
274
+ }
275
+
276
+ // handle failed chunk parsing / validation:
277
+ if (!chunk.success) {
278
+ finishReason = { unified: 'error', raw: undefined };
279
+ controller.enqueue({ type: 'error', error: chunk.error });
280
+ return;
281
+ }
282
+
283
+ const value = chunk.value;
284
+
285
+ // handle error chunks:
286
+ if ('error' in value) {
287
+ finishReason = { unified: 'error', raw: undefined };
288
+ controller.enqueue({ type: 'error', error: value.error });
289
+ return;
290
+ }
291
+
292
+ if (isFirstChunk) {
293
+ isFirstChunk = false;
294
+
295
+ controller.enqueue({
296
+ type: 'response-metadata',
297
+ ...getResponseMetadata(value),
298
+ });
299
+
300
+ controller.enqueue({
301
+ type: 'text-start',
302
+ id: '0',
303
+ });
304
+ }
305
+
306
+ if (value.usage != null) {
307
+ usage = value.usage;
308
+ }
309
+
310
+ const choice = value.choices[0];
311
+
312
+ if (choice?.finish_reason != null) {
313
+ finishReason = {
314
+ unified: mapOpenAICompatibleFinishReason(choice.finish_reason),
315
+ raw: choice.finish_reason ?? undefined,
316
+ };
317
+ }
318
+
319
+ if (choice?.text != null) {
320
+ controller.enqueue({
321
+ type: 'text-delta',
322
+ id: '0',
323
+ delta: choice.text,
324
+ });
325
+ }
326
+ },
327
+
328
+ flush(controller) {
329
+ if (!isFirstChunk) {
330
+ controller.enqueue({ type: 'text-end', id: '0' });
331
+ }
332
+
333
+ controller.enqueue({
334
+ type: 'finish',
335
+ finishReason,
336
+ usage: convertOpenAICompatibleCompletionUsage(usage),
337
+ });
338
+ },
339
+ }),
340
+ ),
341
+ request: { body },
342
+ response: { headers: responseHeaders },
343
+ };
344
+ }
345
+ }
346
+
347
+ const usageSchema = z.object({
348
+ prompt_tokens: z.number(),
349
+ completion_tokens: z.number(),
350
+ total_tokens: z.number(),
351
+ });
352
+
353
+ // limited version of the schema, focussed on what is needed for the implementation
354
+ // this approach limits breakages when the API changes and increases efficiency
355
+ const openaiCompatibleCompletionResponseSchema = z.object({
356
+ id: z.string().nullish(),
357
+ created: z.number().nullish(),
358
+ model: z.string().nullish(),
359
+ choices: z.array(
360
+ z.object({
361
+ text: z.string(),
362
+ finish_reason: z.string(),
363
+ }),
364
+ ),
365
+ usage: usageSchema.nullish(),
366
+ });
367
+
368
+ // limited version of the schema, focussed on what is needed for the implementation
369
+ // this approach limits breakages when the API changes and increases efficiency
370
+ const createOpenAICompatibleCompletionChunkSchema = <
371
+ ERROR_SCHEMA extends z.core.$ZodType,
372
+ >(
373
+ errorSchema: ERROR_SCHEMA,
374
+ ) =>
375
+ z.union([
376
+ z.object({
377
+ id: z.string().nullish(),
378
+ created: z.number().nullish(),
379
+ model: z.string().nullish(),
380
+ choices: z.array(
381
+ z.object({
382
+ text: z.string(),
383
+ finish_reason: z.string().nullish(),
384
+ index: z.number(),
385
+ }),
386
+ ),
387
+ usage: usageSchema.nullish(),
388
+ }),
389
+ errorSchema,
390
+ ]);
@@ -0,0 +1,33 @@
1
+ import { z } from 'zod/v4';
2
+
3
+ export type OpenAICompatibleCompletionModelId = string;
4
+
5
+ export const openaiCompatibleCompletionProviderOptions = z.object({
6
+ /**
7
+ * Echo back the prompt in addition to the completion.
8
+ */
9
+ echo: z.boolean().optional(),
10
+
11
+ /**
12
+ * Modify the likelihood of specified tokens appearing in the completion.
13
+ *
14
+ * Accepts a JSON object that maps tokens (specified by their token ID in
15
+ * the GPT tokenizer) to an associated bias value from -100 to 100.
16
+ */
17
+ logitBias: z.record(z.string(), z.number()).optional(),
18
+
19
+ /**
20
+ * The suffix that comes after a completion of inserted text.
21
+ */
22
+ suffix: z.string().optional(),
23
+
24
+ /**
25
+ * A unique identifier representing your end-user, which can help providers to
26
+ * monitor and detect abuse.
27
+ */
28
+ user: z.string().optional(),
29
+ });
30
+
31
+ export type OpenAICompatibleCompletionProviderOptions = z.infer<
32
+ typeof openaiCompatibleCompletionProviderOptions
33
+ >;
@@ -0,0 +1,171 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { EmbeddingModelV3Embedding } from '@ai-sdk/provider';
3
+ import { createTestServer } from '@ai-sdk/test-server/with-vitest';
4
+ import { createOpenAICompatible } from '../openai-compatible-provider';
5
+
6
+ const dummyEmbeddings = [
7
+ [0.1, 0.2, 0.3, 0.4, 0.5],
8
+ [0.6, 0.7, 0.8, 0.9, 1.0],
9
+ ];
10
+ const testValues = ['sunny day at the beach', 'rainy day in the city'];
11
+
12
+ const provider = createOpenAICompatible({
13
+ baseURL: 'https://my.api.com/v1/',
14
+ name: 'test-provider',
15
+ headers: {
16
+ Authorization: `Bearer test-api-key`,
17
+ },
18
+ });
19
+ const model = provider.embeddingModel('text-embedding-3-large');
20
+
21
+ const server = createTestServer({
22
+ 'https://my.api.com/v1/embeddings': {},
23
+ });
24
+
25
+ describe('doEmbed', () => {
26
+ function prepareJsonResponse({
27
+ embeddings = dummyEmbeddings,
28
+ usage = { prompt_tokens: 8, total_tokens: 8 },
29
+ headers,
30
+ }: {
31
+ embeddings?: EmbeddingModelV3Embedding[];
32
+ usage?: { prompt_tokens: number; total_tokens: number };
33
+ headers?: Record<string, string>;
34
+ } = {}) {
35
+ server.urls['https://my.api.com/v1/embeddings'].response = {
36
+ type: 'json-value',
37
+ headers,
38
+ body: {
39
+ object: 'list',
40
+ data: embeddings.map((embedding, i) => ({
41
+ object: 'embedding',
42
+ index: i,
43
+ embedding,
44
+ })),
45
+ model: 'text-embedding-3-large',
46
+ usage,
47
+ },
48
+ };
49
+ }
50
+
51
+ it('should extract embedding', async () => {
52
+ prepareJsonResponse();
53
+
54
+ const { embeddings } = await model.doEmbed({ values: testValues });
55
+
56
+ expect(embeddings).toStrictEqual(dummyEmbeddings);
57
+ });
58
+
59
+ it('should expose the raw response headers', async () => {
60
+ prepareJsonResponse({
61
+ headers: { 'test-header': 'test-value' },
62
+ });
63
+
64
+ const { response } = await model.doEmbed({ values: testValues });
65
+
66
+ expect(response?.headers).toStrictEqual({
67
+ // default headers:
68
+ 'content-length': '236',
69
+ 'content-type': 'application/json',
70
+
71
+ // custom header
72
+ 'test-header': 'test-value',
73
+ });
74
+ });
75
+
76
+ it('should extract usage', async () => {
77
+ prepareJsonResponse({
78
+ usage: { prompt_tokens: 20, total_tokens: 20 },
79
+ });
80
+
81
+ const { usage } = await model.doEmbed({ values: testValues });
82
+
83
+ expect(usage).toStrictEqual({ tokens: 20 });
84
+ });
85
+
86
+ it('should pass the model and the values', async () => {
87
+ prepareJsonResponse();
88
+
89
+ await model.doEmbed({ values: testValues });
90
+
91
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
92
+ model: 'text-embedding-3-large',
93
+ input: testValues,
94
+ encoding_format: 'float',
95
+ });
96
+ });
97
+
98
+ it('should pass the dimensions setting', async () => {
99
+ prepareJsonResponse();
100
+
101
+ await provider.embeddingModel('text-embedding-3-large').doEmbed({
102
+ values: testValues,
103
+ providerOptions: {
104
+ openaiCompatible: {
105
+ dimensions: 64,
106
+ },
107
+ },
108
+ });
109
+
110
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
111
+ model: 'text-embedding-3-large',
112
+ input: testValues,
113
+ encoding_format: 'float',
114
+ dimensions: 64,
115
+ });
116
+ });
117
+
118
+ it('should pass settings with deprecated openai-compatible key and emit warning', async () => {
119
+ prepareJsonResponse();
120
+
121
+ const result = await provider
122
+ .embeddingModel('text-embedding-3-large')
123
+ .doEmbed({
124
+ values: testValues,
125
+ providerOptions: {
126
+ 'openai-compatible': {
127
+ dimensions: 64,
128
+ },
129
+ },
130
+ });
131
+
132
+ expect(await server.calls[0].requestBodyJson).toStrictEqual({
133
+ model: 'text-embedding-3-large',
134
+ input: testValues,
135
+ encoding_format: 'float',
136
+ dimensions: 64,
137
+ });
138
+
139
+ expect(result.warnings).toContainEqual({
140
+ type: 'other',
141
+ message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.`,
142
+ });
143
+ });
144
+
145
+ it('should pass headers', async () => {
146
+ prepareJsonResponse();
147
+
148
+ const provider = createOpenAICompatible({
149
+ baseURL: 'https://my.api.com/v1/',
150
+ name: 'test-provider',
151
+ headers: {
152
+ Authorization: `Bearer test-api-key`,
153
+ 'Custom-Provider-Header': 'provider-header-value',
154
+ },
155
+ });
156
+
157
+ await provider.embeddingModel('text-embedding-3-large').doEmbed({
158
+ values: testValues,
159
+ headers: {
160
+ 'Custom-Request-Header': 'request-header-value',
161
+ },
162
+ });
163
+
164
+ expect(server.calls[0].requestHeaders).toStrictEqual({
165
+ authorization: 'Bearer test-api-key',
166
+ 'content-type': 'application/json',
167
+ 'custom-provider-header': 'provider-header-value',
168
+ 'custom-request-header': 'request-header-value',
169
+ });
170
+ });
171
+ });