Skip to content

Commit 816ff67

Browse files
fix(openai-compatible): honor camelCase providerOptions key in chat and completion models (#14135)
## Summary Fixes #14105 The [docs](https://ai-sdk.dev/providers/openai-compatible-providers#chat-model-options) state that `providerOptions` keys should use camelCase (e.g. `provider-name` → `providerName`), but chat and completion models only looked up the raw provider name. The image model already had the correct fallback behavior. ## Root Cause When using `createOpenAICompatible({ name: 'provider-name' })`, the image model merges both raw and camelCase keys: ```ts // image model (correct) ...providerOptions[this.providerOptionsKey], ...providerOptions[toCamelCase(this.providerOptionsKey)], ``` But chat and completion models only checked the raw key: ```ts // chat/completion (bug) ...providerOptions?.[this.providerOptionsName] // only raw key ``` This means `providerOptions.providerName` was ignored in chat/completion while working in image. ## Fix - Extracted `toCamelCase` into a shared module (`to-camel-case.ts`) used by all three models - Added camelCase key fallback in chat model (`parseProviderOptions` + direct spread) - Added camelCase key fallback in completion model (same pattern) - Removed the local `toCamelCase` from image model (now uses shared import) The camelCase key takes precedence over the raw key (via `Object.assign` ordering), matching the documented behavior. ## Changed Files - `packages/openai-compatible/src/to-camel-case.ts` — new shared utility - `packages/openai-compatible/src/chat/openai-compatible-chat-language-model.ts` — add camelCase fallback - `packages/openai-compatible/src/completion/openai-compatible-completion-language-model.ts` — add camelCase fallback - `packages/openai-compatible/src/image/openai-compatible-image-model.ts` — use shared import --------- Co-authored-by: Aayush Kapoor <83492835+aayush-kapoor@users.noreply.github.com> Co-authored-by: Aayush Kapoor <aayushkapoor34@gmail.com>
1 parent 3ea5a07 commit 816ff67

8 files changed

Lines changed: 481 additions & 17 deletions

.changeset/fast-ears-beam.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@ai-sdk/openai-compatible": patch
3+
---
4+
5+
fix(openai-compatible): honor camelCase providerOptions key in chat and completion models

packages/openai-compatible/src/chat/openai-compatible-chat-language-model.test.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,172 @@ describe('doGenerate', () => {
520520
`);
521521
});
522522

523+
describe('camelCase provider options', () => {
524+
it('should accept camelCase provider options key for hyphenated provider name', async () => {
525+
prepareJsonResponse({ content: 'Hello!' });
526+
527+
await provider('grok-3').doGenerate({
528+
providerOptions: {
529+
testProvider: {
530+
someCustomOption: 'test-value',
531+
},
532+
},
533+
prompt: TEST_PROMPT,
534+
});
535+
536+
expect(await server.calls[0].requestBodyJson).toMatchObject({
537+
someCustomOption: 'test-value',
538+
});
539+
});
540+
541+
it('should prefer camelCase options over raw-name options', async () => {
542+
prepareJsonResponse({ content: 'Hello!' });
543+
544+
await provider('grok-3').doGenerate({
545+
providerOptions: {
546+
'test-provider': {
547+
someCustomOption: 'raw-value',
548+
},
549+
testProvider: {
550+
someCustomOption: 'camel-value',
551+
},
552+
},
553+
prompt: TEST_PROMPT,
554+
});
555+
556+
expect(await server.calls[0].requestBodyJson).toMatchObject({
557+
someCustomOption: 'camel-value',
558+
});
559+
});
560+
561+
it('should use camelCase metadata key when camelCase provider options are used', async () => {
562+
prepareJsonResponse({
563+
content: 'Hello!',
564+
usage: {
565+
prompt_tokens: 20,
566+
completion_tokens: 30,
567+
total_tokens: 50,
568+
completion_tokens_details: {
569+
accepted_prediction_tokens: 15,
570+
},
571+
},
572+
});
573+
574+
const result = await provider('grok-3').doGenerate({
575+
providerOptions: {
576+
testProvider: { reasoningEffort: 'high' },
577+
},
578+
prompt: TEST_PROMPT,
579+
});
580+
581+
expect(result.providerMetadata).toHaveProperty('testProvider');
582+
expect(result.providerMetadata).not.toHaveProperty('test-provider');
583+
expect(result.providerMetadata!['testProvider']).toMatchObject({
584+
acceptedPredictionTokens: 15,
585+
});
586+
});
587+
588+
it('should use raw metadata key when raw provider options are used', async () => {
589+
prepareJsonResponse({
590+
content: 'Hello!',
591+
usage: {
592+
prompt_tokens: 20,
593+
completion_tokens: 30,
594+
total_tokens: 50,
595+
completion_tokens_details: {
596+
accepted_prediction_tokens: 15,
597+
},
598+
},
599+
});
600+
601+
const result = await provider('grok-3').doGenerate({
602+
providerOptions: {
603+
'test-provider': { reasoningEffort: 'high' },
604+
},
605+
prompt: TEST_PROMPT,
606+
});
607+
608+
expect(result.providerMetadata).toHaveProperty('test-provider');
609+
expect(result.providerMetadata!['test-provider']).toMatchObject({
610+
acceptedPredictionTokens: 15,
611+
});
612+
});
613+
614+
it('should use raw metadata key when no provider options are passed', async () => {
615+
prepareJsonResponse({ content: 'Hello!' });
616+
617+
const result = await provider('grok-3').doGenerate({
618+
prompt: TEST_PROMPT,
619+
});
620+
621+
expect(result.providerMetadata).toHaveProperty('test-provider');
622+
});
623+
624+
it('should include thought signature in providerMetadata with camelCase key', async () => {
625+
prepareJsonResponse({
626+
tool_calls: [
627+
{
628+
id: 'call-1',
629+
type: 'function' as const,
630+
function: {
631+
name: 'test_tool',
632+
arguments: '{"arg":"value"}',
633+
},
634+
extra_content: {
635+
google: { thought_signature: '<Sig>' },
636+
},
637+
},
638+
],
639+
});
640+
641+
const result = await provider('grok-3').doGenerate({
642+
providerOptions: { testProvider: {} },
643+
prompt: TEST_PROMPT,
644+
});
645+
646+
expect(result.content).toMatchObject([
647+
{
648+
type: 'tool-call',
649+
providerMetadata: {
650+
testProvider: { thoughtSignature: '<Sig>' },
651+
},
652+
},
653+
]);
654+
});
655+
656+
it('should include thought signature in providerMetadata with raw key', async () => {
657+
prepareJsonResponse({
658+
tool_calls: [
659+
{
660+
id: 'call-1',
661+
type: 'function' as const,
662+
function: {
663+
name: 'test_tool',
664+
arguments: '{"arg":"value"}',
665+
},
666+
extra_content: {
667+
google: { thought_signature: '<Sig>' },
668+
},
669+
},
670+
],
671+
});
672+
673+
const result = await provider('grok-3').doGenerate({
674+
providerOptions: { 'test-provider': {} },
675+
prompt: TEST_PROMPT,
676+
});
677+
678+
expect(result.content).toMatchObject([
679+
{
680+
type: 'tool-call',
681+
providerMetadata: {
682+
'test-provider': { thoughtSignature: '<Sig>' },
683+
},
684+
},
685+
]);
686+
});
687+
});
688+
523689
it('should pass tools and toolChoice', async () => {
524690
prepareJsonResponse({ content: '' });
525691

@@ -2922,6 +3088,115 @@ describe('doStream', () => {
29223088
`);
29233089
});
29243090

3091+
describe('camelCase provider options', () => {
3092+
it('should accept camelCase provider options key for hyphenated provider name', async () => {
3093+
prepareStreamResponse({ content: [] });
3094+
3095+
await provider('grok-3').doStream({
3096+
providerOptions: {
3097+
testProvider: {
3098+
someCustomOption: 'test-value',
3099+
},
3100+
},
3101+
prompt: TEST_PROMPT,
3102+
includeRawChunks: false,
3103+
});
3104+
3105+
expect(await server.calls[0].requestBodyJson).toMatchObject({
3106+
someCustomOption: 'test-value',
3107+
});
3108+
});
3109+
3110+
it('should prefer camelCase options over raw-name options', async () => {
3111+
prepareStreamResponse({ content: [] });
3112+
3113+
await provider('grok-3').doStream({
3114+
providerOptions: {
3115+
'test-provider': { someCustomOption: 'raw-value' },
3116+
testProvider: { someCustomOption: 'camel-value' },
3117+
},
3118+
prompt: TEST_PROMPT,
3119+
includeRawChunks: false,
3120+
});
3121+
3122+
expect(await server.calls[0].requestBodyJson).toMatchObject({
3123+
someCustomOption: 'camel-value',
3124+
});
3125+
});
3126+
3127+
it('should use camelCase metadata key in finish event when camelCase options are used', async () => {
3128+
prepareStreamResponse({ content: ['Hello'] });
3129+
3130+
const { stream } = await provider('grok-3').doStream({
3131+
providerOptions: { testProvider: {} },
3132+
prompt: TEST_PROMPT,
3133+
includeRawChunks: false,
3134+
});
3135+
3136+
const parts = await convertReadableStreamToArray(stream);
3137+
const finishPart = parts.find(part => part.type === 'finish');
3138+
3139+
expect(finishPart?.providerMetadata).toHaveProperty('testProvider');
3140+
expect(finishPart?.providerMetadata).not.toHaveProperty('test-provider');
3141+
});
3142+
3143+
it('should use raw metadata key in finish event when raw options are used', async () => {
3144+
prepareStreamResponse({ content: ['Hello'] });
3145+
3146+
const { stream } = await provider('grok-3').doStream({
3147+
providerOptions: { 'test-provider': {} },
3148+
prompt: TEST_PROMPT,
3149+
includeRawChunks: false,
3150+
});
3151+
3152+
const parts = await convertReadableStreamToArray(stream);
3153+
const finishPart = parts.find(part => part.type === 'finish');
3154+
3155+
expect(finishPart?.providerMetadata).toHaveProperty('test-provider');
3156+
});
3157+
3158+
it('should use raw metadata key in finish event when no provider options are passed', async () => {
3159+
prepareStreamResponse({ content: ['Hello'] });
3160+
3161+
const { stream } = await provider('grok-3').doStream({
3162+
prompt: TEST_PROMPT,
3163+
includeRawChunks: false,
3164+
});
3165+
3166+
const parts = await convertReadableStreamToArray(stream);
3167+
const finishPart = parts.find(part => part.type === 'finish');
3168+
3169+
expect(finishPart?.providerMetadata).toHaveProperty('test-provider');
3170+
});
3171+
3172+
it('should use camelCase metadata key for thought signatures in streamed tool calls', async () => {
3173+
server.urls['https://my.api.com/v1/chat/completions'].response = {
3174+
type: 'stream-chunks',
3175+
chunks: [
3176+
`data: {"id":"chat-id","choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"call-1","type":"function","function":{"name":"test_tool","arguments":"{\\"a\\":1}"},"extra_content":{"google":{"thought_signature":"<Sig>"}}}]},"finish_reason":null}]}\n\n`,
3177+
`data: {"id":"chat-id","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"total_tokens":15}}\n\n`,
3178+
'data: [DONE]\n\n',
3179+
],
3180+
};
3181+
3182+
const { stream } = await provider('grok-3').doStream({
3183+
providerOptions: { testProvider: {} },
3184+
prompt: TEST_PROMPT,
3185+
includeRawChunks: false,
3186+
});
3187+
3188+
const parts = await convertReadableStreamToArray(stream);
3189+
const toolCallEvent = parts.find(part => part.type === 'tool-call');
3190+
3191+
expect(toolCallEvent).toMatchObject({
3192+
type: 'tool-call',
3193+
providerMetadata: {
3194+
testProvider: { thoughtSignature: '<Sig>' },
3195+
},
3196+
});
3197+
});
3198+
});
3199+
29253200
it('should send request body', async () => {
29263201
prepareStreamResponse({ content: [] });
29273202

0 commit comments

Comments
 (0)