diff --git a/.changeset/quiet-tomatoes-double.md b/.changeset/quiet-tomatoes-double.md new file mode 100644 index 000000000000..4972d92298e0 --- /dev/null +++ b/.changeset/quiet-tomatoes-double.md @@ -0,0 +1,7 @@ +--- +"@ai-sdk/openai-compatible": patch +"@ai-sdk/provider": patch +"ai": patch +--- + +feat(openai-compatible): emit warning when using kebab-case instead of camelCase diff --git a/packages/ai/src/logger/log-warnings.test.ts b/packages/ai/src/logger/log-warnings.test.ts index 8e38151d0a50..f92bb0935990 100644 --- a/packages/ai/src/logger/log-warnings.test.ts +++ b/packages/ai/src/logger/log-warnings.test.ts @@ -220,6 +220,11 @@ describe('logWarnings', () => { feature: 'voice', details: 'detail2', }, + { + type: 'deprecated', + setting: "providerOptions key 'old-key'", + message: "Use 'oldKey' instead.", + }, { type: 'other', message: 'other msg', @@ -229,7 +234,7 @@ describe('logWarnings', () => { logWarnings({ warnings, provider: 'zzz', model: 'MMM' }); expect(mockConsoleInfo).toHaveBeenCalledTimes(1); - expect(mockConsoleWarn).toHaveBeenCalledTimes(3); + expect(mockConsoleWarn).toHaveBeenCalledTimes(4); expect(mockConsoleWarn).toHaveBeenNthCalledWith( 1, 'AI SDK Warning (zzz / MMM): ' + @@ -242,6 +247,10 @@ describe('logWarnings', () => { ); expect(mockConsoleWarn).toHaveBeenNthCalledWith( 3, + `AI SDK Warning (zzz / MMM): Deprecated: "providerOptions key 'old-key'". Use 'oldKey' instead.`, + ); + expect(mockConsoleWarn).toHaveBeenNthCalledWith( + 4, 'AI SDK Warning (zzz / MMM): other msg', ); }); diff --git a/packages/ai/src/logger/log-warnings.ts b/packages/ai/src/logger/log-warnings.ts index 4b0d33adff0d..25999f4aa1dd 100644 --- a/packages/ai/src/logger/log-warnings.ts +++ b/packages/ai/src/logger/log-warnings.ts @@ -66,6 +66,10 @@ function formatWarning({ return message; } + case 'deprecated': { + return `${prefix} Deprecated: "${warning.setting}". ${warning.message}`; + } + case 'other': { return `${prefix} ${warning.message}`; } diff --git a/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts b/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts index d021565ae1bc..10d33f1b5965 100644 --- a/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts +++ b/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts @@ -463,10 +463,15 @@ describe('doGenerate', () => { } `); - expect(result.warnings).toContainEqual({ - type: 'other', - message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.`, - }); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "message": "Use 'openaiCompatible' instead.", + "setting": "providerOptions key 'openai-compatible'", + "type": "deprecated", + }, + ] + `); }); it('should include provider-specific options', async () => { @@ -611,6 +616,40 @@ describe('doGenerate', () => { }); }); + it('should emit deprecated warning when raw provider options key is used', async () => { + prepareJsonResponse({ content: 'Hello!' }); + + const result = await provider('grok-3').doGenerate({ + providerOptions: { + 'test-provider': { reasoningEffort: 'high' }, + }, + prompt: TEST_PROMPT, + }); + + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated", + }, + ] + `); + }); + + it('should not emit deprecated warning when camelCase provider options key is used', async () => { + prepareJsonResponse({ content: 'Hello!' }); + + const result = await provider('grok-3').doGenerate({ + providerOptions: { + testProvider: { reasoningEffort: 'high' }, + }, + prompt: TEST_PROMPT, + }); + + expect(result.warnings).toMatchInlineSnapshot(`[]`); + }); + it('should use raw metadata key when no provider options are passed', async () => { prepareJsonResponse({ content: 'Hello!' }); @@ -3124,6 +3163,48 @@ describe('doStream', () => { }); }); + it('should emit deprecated warning when raw provider options key is used', async () => { + prepareStreamResponse({ content: ['Hello'] }); + + const { stream } = await provider('grok-3').doStream({ + providerOptions: { + 'test-provider': { reasoningEffort: 'high' }, + }, + prompt: TEST_PROMPT, + includeRawChunks: false, + }); + + const parts = await convertReadableStreamToArray(stream); + const streamStart = parts.find(part => part.type === 'stream-start'); + + expect(streamStart?.warnings).toMatchInlineSnapshot(` + [ + { + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated", + }, + ] + `); + }); + + it('should not emit deprecated warning when camelCase provider options key is used', async () => { + prepareStreamResponse({ content: ['Hello'] }); + + const { stream } = await provider('grok-3').doStream({ + providerOptions: { + testProvider: { reasoningEffort: 'high' }, + }, + prompt: TEST_PROMPT, + includeRawChunks: false, + }); + + const parts = await convertReadableStreamToArray(stream); + const streamStart = parts.find(part => part.type === 'stream-start'); + + expect(streamStart?.warnings).toMatchInlineSnapshot(`[]`); + }); + it('should use camelCase metadata key in finish event when camelCase options are used', async () => { prepareStreamResponse({ content: ['Hello'] }); diff --git a/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.ts b/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.ts index 4cc445172d67..5a10e4a59a0a 100644 --- a/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.ts +++ b/packages/openai-compatible/src/chat/openai-compatible-chat-language-model.ts @@ -26,7 +26,11 @@ import { ResponseHandler, } from '@ai-sdk/provider-utils'; import { z } from 'zod/v4'; -import { resolveProviderOptionsKey, toCamelCase } from '../utils/to-camel-case'; +import { + resolveProviderOptionsKey, + toCamelCase, + warnIfDeprecatedProviderOptionsKey, +} from '../utils/to-camel-case'; import { defaultOpenAICompatibleErrorStructure, ProviderErrorStructure, @@ -140,11 +144,19 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV4 { if (deprecatedOptions != null) { warnings.push({ - type: 'other', - message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.`, + type: 'deprecated', + setting: "providerOptions key 'openai-compatible'", + message: "Use 'openaiCompatible' instead.", }); } + // Warn when the raw (non-camelCase) provider name is used + warnIfDeprecatedProviderOptionsKey({ + rawName: this.providerOptionsName, + providerOptions, + warnings, + }); + const compatibleOptions = Object.assign( deprecatedOptions ?? {}, (await parseProviderOptions({ diff --git a/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.test.ts b/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.test.ts index d318e3e1dd3b..742d0d919e00 100644 --- a/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.test.ts +++ b/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.test.ts @@ -429,6 +429,44 @@ describe('doGenerate', () => { someCustomOption: 'camel-value', }); }); + + it('should emit deprecated warning when raw provider options key is used', async () => { + prepareJsonResponse({ content: '' }); + + const result = await provider + .completionModel('gpt-3.5-turbo-instruct') + .doGenerate({ + prompt: TEST_PROMPT, + providerOptions: { + 'test-provider': { someCustomOption: 'test-value' }, + }, + }); + + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated", + }, + ] + `); + }); + + it('should not emit deprecated warning when camelCase provider options key is used', async () => { + prepareJsonResponse({ content: '' }); + + const result = await provider + .completionModel('gpt-3.5-turbo-instruct') + .doGenerate({ + prompt: TEST_PROMPT, + providerOptions: { + testProvider: { someCustomOption: 'test-value' }, + }, + }); + + expect(result.warnings).toMatchInlineSnapshot(`[]`); + }); }); }); diff --git a/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.ts b/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.ts index a485eb117423..f68d71ef37c3 100644 --- a/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.ts +++ b/packages/openai-compatible/src/completion/openai-compatible-completion-language-model.ts @@ -21,7 +21,10 @@ import { ResponseHandler, } from '@ai-sdk/provider-utils'; import { z } from 'zod/v4'; -import { toCamelCase } from '../utils/to-camel-case'; +import { + toCamelCase, + warnIfDeprecatedProviderOptionsKey, +} from '../utils/to-camel-case'; import { defaultOpenAICompatibleErrorStructure, ProviderErrorStructure, @@ -102,6 +105,13 @@ export class OpenAICompatibleCompletionLanguageModel implements LanguageModelV4 }: LanguageModelV4CallOptions) { const warnings: SharedV4Warning[] = []; + // Warn when the raw (non-camelCase) provider name is used + warnIfDeprecatedProviderOptionsKey({ + rawName: this.providerOptionsName, + providerOptions, + warnings, + }); + // Parse provider options (support both raw and camelCase keys) const completionOptions = Object.assign( (await parseProviderOptions({ diff --git a/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.test.ts b/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.test.ts index 67f30ff9c810..beed14208f7f 100644 --- a/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.test.ts +++ b/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.test.ts @@ -136,10 +136,57 @@ describe('doEmbed', () => { dimensions: 64, }); - expect(result.warnings).toContainEqual({ - type: 'other', - message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.`, - }); + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "message": "Use 'openaiCompatible' instead.", + "setting": "providerOptions key 'openai-compatible'", + "type": "deprecated", + }, + ] + `); + }); + + it('should emit deprecated warning when raw provider name key is used', async () => { + prepareJsonResponse(); + + const result = await provider + .embeddingModel('text-embedding-3-large') + .doEmbed({ + values: testValues, + providerOptions: { + 'test-provider': { + dimensions: 64, + }, + }, + }); + + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "message": "Use 'testProvider' instead.", + "setting": "providerOptions key 'test-provider'", + "type": "deprecated", + }, + ] + `); + }); + + it('should not emit deprecated warning when camelCase provider name key is used', async () => { + prepareJsonResponse(); + + const result = await provider + .embeddingModel('text-embedding-3-large') + .doEmbed({ + values: testValues, + providerOptions: { + testProvider: { + dimensions: 64, + }, + }, + }); + + expect(result.warnings).toMatchInlineSnapshot(`[]`); }); it('should pass headers', async () => { diff --git a/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.ts b/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.ts index 1228cd2ae063..bbf4296402ef 100644 --- a/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.ts +++ b/packages/openai-compatible/src/embedding/openai-compatible-embedding-model.ts @@ -20,6 +20,7 @@ import { defaultOpenAICompatibleErrorStructure, ProviderErrorStructure, } from '../openai-compatible-error'; +import { warnIfDeprecatedProviderOptionsKey } from '../utils/to-camel-case'; type OpenAICompatibleEmbeddingConfig = { /** @@ -88,11 +89,19 @@ export class OpenAICompatibleEmbeddingModel implements EmbeddingModelV4 { if (deprecatedOptions != null) { warnings.push({ - type: 'other', - message: `The 'openai-compatible' key in providerOptions is deprecated. Use 'openaiCompatible' instead.`, + type: 'deprecated', + setting: "providerOptions key 'openai-compatible'", + message: "Use 'openaiCompatible' instead.", }); } + // Warn when the raw (non-camelCase) provider name is used + warnIfDeprecatedProviderOptionsKey({ + rawName: this.providerOptionsName, + providerOptions, + warnings, + }); + const compatibleOptions = Object.assign( deprecatedOptions ?? {}, (await parseProviderOptions({ diff --git a/packages/openai-compatible/src/image/openai-compatible-image-model.test.ts b/packages/openai-compatible/src/image/openai-compatible-image-model.test.ts index fbde2ee3df65..0c401c2bfdf7 100644 --- a/packages/openai-compatible/src/image/openai-compatible-image-model.test.ts +++ b/packages/openai-compatible/src/image/openai-compatible-image-model.test.ts @@ -134,6 +134,50 @@ describe('OpenAICompatibleImageModel', () => { }); }); + it('should emit deprecated warning when raw provider options key is used for hyphenated provider', async () => { + const model = new OpenAICompatibleImageModel('dall-e-3', { + provider: 'black-forest-labs.image', + headers: () => ({ Authorization: 'Bearer test-key' }), + url: ({ modelId, path }) => `https://api.example.com/${modelId}${path}`, + }); + + const result = await model.doGenerate( + createDefaultGenerateParams({ + providerOptions: { + 'black-forest-labs': { quality: 'hd' }, + }, + }), + ); + + expect(result.warnings).toMatchInlineSnapshot(` + [ + { + "message": "Use 'blackForestLabs' instead.", + "setting": "providerOptions key 'black-forest-labs'", + "type": "deprecated", + }, + ] + `); + }); + + it('should not emit deprecated warning when camelCase provider options key is used', async () => { + const model = new OpenAICompatibleImageModel('dall-e-3', { + provider: 'black-forest-labs.image', + headers: () => ({ Authorization: 'Bearer test-key' }), + url: ({ modelId, path }) => `https://api.example.com/${modelId}${path}`, + }); + + const result = await model.doGenerate( + createDefaultGenerateParams({ + providerOptions: { + blackForestLabs: { quality: 'hd' }, + }, + }), + ); + + expect(result.warnings).toMatchInlineSnapshot(`[]`); + }); + it('should add warnings for unsupported settings', async () => { const model = createBasicModel(); diff --git a/packages/openai-compatible/src/image/openai-compatible-image-model.ts b/packages/openai-compatible/src/image/openai-compatible-image-model.ts index 642efbff1c96..9c1e9ddd5455 100644 --- a/packages/openai-compatible/src/image/openai-compatible-image-model.ts +++ b/packages/openai-compatible/src/image/openai-compatible-image-model.ts @@ -4,7 +4,10 @@ import { SharedV4ProviderOptions, SharedV4Warning, } from '@ai-sdk/provider'; -import { toCamelCase } from '../utils/to-camel-case'; +import { + toCamelCase, + warnIfDeprecatedProviderOptionsKey, +} from '../utils/to-camel-case'; import { combineHeaders, convertBase64ToUint8Array, @@ -54,10 +57,15 @@ export class OpenAICompatibleImageModel implements ImageModelV4 { private readonly config: OpenAICompatibleImageModelConfig, ) {} - // TODO: deprecate non-camelCase keys and remove in future major version private getArgs( providerOptions: SharedV4ProviderOptions, + warnings: SharedV4Warning[], ): Record { + warnIfDeprecatedProviderOptionsKey({ + rawName: this.providerOptionsKey, + providerOptions, + warnings, + }); return { ...providerOptions[this.providerOptionsKey], ...providerOptions[toCamelCase(this.providerOptionsKey)], @@ -95,7 +103,7 @@ export class OpenAICompatibleImageModel implements ImageModelV4 { const currentDate = this.config._internal?.currentDate?.() ?? new Date(); - const args = this.getArgs(providerOptions); + const args = this.getArgs(providerOptions, warnings); // Image editing mode - use form data and /images/edits endpoint if (files != null && files.length > 0) { diff --git a/packages/openai-compatible/src/utils/to-camel-case.test.ts b/packages/openai-compatible/src/utils/to-camel-case.test.ts index 7718ad827250..6b3b1aa768f5 100644 --- a/packages/openai-compatible/src/utils/to-camel-case.test.ts +++ b/packages/openai-compatible/src/utils/to-camel-case.test.ts @@ -1,5 +1,10 @@ +import { SharedV4Warning } from '@ai-sdk/provider'; import { describe, it, expect } from 'vitest'; -import { toCamelCase, resolveProviderOptionsKey } from './to-camel-case'; +import { + toCamelCase, + resolveProviderOptionsKey, + warnIfDeprecatedProviderOptionsKey, +} from './to-camel-case'; describe('toCamelCase', () => { it('should convert hyphenated names to camelCase', () => { @@ -71,3 +76,61 @@ describe('resolveProviderOptionsKey', () => { ); }); }); + +describe('warnIfDeprecatedProviderOptionsKey', () => { + it('should push a deprecated warning when raw key is used and differs from camelCase', () => { + const warnings: SharedV4Warning[] = []; + warnIfDeprecatedProviderOptionsKey({ + rawName: 'black-forest-labs', + providerOptions: { 'black-forest-labs': { style: 'hd' } }, + warnings, + }); + expect(warnings).toEqual([ + { + type: 'deprecated', + setting: "providerOptions key 'black-forest-labs'", + message: "Use 'blackForestLabs' instead.", + }, + ]); + }); + + it('should not push a warning when only camelCase key is used', () => { + const warnings: SharedV4Warning[] = []; + warnIfDeprecatedProviderOptionsKey({ + rawName: 'black-forest-labs', + providerOptions: { blackForestLabs: { style: 'hd' } }, + warnings, + }); + expect(warnings).toEqual([]); + }); + + it('should not push a warning when raw name is already camelCase', () => { + const warnings: SharedV4Warning[] = []; + warnIfDeprecatedProviderOptionsKey({ + rawName: 'openai', + providerOptions: { openai: { user: 'test' } }, + warnings, + }); + expect(warnings).toEqual([]); + }); + + it('should not push a warning when raw key is not present in providerOptions', () => { + const warnings: SharedV4Warning[] = []; + warnIfDeprecatedProviderOptionsKey({ + rawName: 'black-forest-labs', + providerOptions: {}, + warnings, + }); + expect(warnings).toEqual([]); + }); + + it('should not push a warning when providerOptions is undefined', () => { + const warnings: SharedV4Warning[] = []; + warnIfDeprecatedProviderOptionsKey({ + rawName: 'black-forest-labs', + providerOptions: undefined, + warnings, + }); + expect(warnings).toEqual([]); + }); +}); diff --git a/packages/openai-compatible/src/utils/to-camel-case.ts b/packages/openai-compatible/src/utils/to-camel-case.ts index 573d21ad3a11..34c62a9345ab 100644 --- a/packages/openai-compatible/src/utils/to-camel-case.ts +++ b/packages/openai-compatible/src/utils/to-camel-case.ts @@ -1,3 +1,5 @@ +import { SharedV4Warning } from '@ai-sdk/provider'; + export function toCamelCase(str: string): string { return str.replace(/[_-]([a-z])/g, g => g[1].toUpperCase()); } @@ -17,3 +19,25 @@ export function resolveProviderOptionsKey( } return rawName; } + +/** +Pushes a deprecation warning when the user supplies providerOptions under a non-camelCase key +*/ +export function warnIfDeprecatedProviderOptionsKey({ + rawName, + providerOptions, + warnings, +}: { + rawName: string; + providerOptions: Record | undefined; + warnings: SharedV4Warning[]; +}): void { + const camelName = toCamelCase(rawName); + if (camelName !== rawName && providerOptions?.[rawName] != null) { + warnings.push({ + type: 'deprecated', + setting: `providerOptions key '${rawName}'`, + message: `Use '${camelName}' instead.`, + }); + } +} diff --git a/packages/provider/src/shared/v4/shared-v4-warning.ts b/packages/provider/src/shared/v4/shared-v4-warning.ts index d1d8d7a42a1c..87a951ace47f 100644 --- a/packages/provider/src/shared/v4/shared-v4-warning.ts +++ b/packages/provider/src/shared/v4/shared-v4-warning.ts @@ -37,6 +37,22 @@ export type SharedV4Warning = */ details?: string; } + | { + /** + * A deprecated feature or option is being used. + */ + type: 'deprecated'; + + /** + * The deprecated setting or feature name. + */ + setting: string; + + /** + * A human-readable message explaining what to use instead. + */ + message: string; + } | { /** * Other warning.