From 01b817e9ead9b2df439d051b3e1e56bf898637c9 Mon Sep 17 00:00:00 2001 From: Yihan Zhu <48186361+yihanzhu@users.noreply.github.com> Date: Thu, 20 Aug 2026 07:53:47 -0400 Subject: [PATCH 1/2] fix(runtime): emit camelCase OpenAI-compatible provider option keys The AI SDK resolves openai-compatible providerOptions under the camelCase alias of the provider name and flags raw dashed keys as deprecated on every doGenerate. Derive the key via toCamelCase for all openai-compatible identities (extends #2463, which only covered custom relay slugs) and fix the three direct dashed emissions. The open-responses path is unchanged: that SDK resolves only the raw name. Closes #1430 Generated-by: Claude Code (Fable 5) Co-Authored-By: Claude Fable 5 --- .../__tests__/model-factory-thinking.test.ts | 74 +++++++++++++++---- .../src/__tests__/request-shape.test.ts | 4 +- packages/runtime/src/model-factory.ts | 21 +++--- 3 files changed, 70 insertions(+), 29 deletions(-) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index 919ac5e78c..d91ed07e1f 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -264,7 +264,7 @@ describe('buildProviderOptions: thinking level', () => { }); // Copilot defaults to its OpenAI-compatible chat wire without a protocol hint. assert.deepEqual(buildProviderOptions(conn('github-copilot'), 'gpt-5.4', 'high'), { - 'github-copilot': { reasoningEffort: 'high' }, + githubCopilot: { reasoningEffort: 'high' }, }); }); @@ -295,20 +295,20 @@ describe('buildProviderOptions: thinking level', () => { ); assert.deepEqual(buildProviderOptions(conn('cloudflare-workers-ai'), modelId), {}); assert.deepEqual(buildProviderOptions(conn('cloudflare-workers-ai'), modelId, 'high'), { - 'cloudflare-workers-ai': { reasoningEffort: 'high' }, + cloudflareWorkersAi: { reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('cloudflare-workers-ai'), modelId, 'off'), { - 'cloudflare-workers-ai': { chat_template_kwargs: { thinking: false } }, + cloudflareWorkersAi: { chat_template_kwargs: { thinking: false } }, }); }); test('StepFun Step Plan sends only officially supported reasoning effort levels', () => { assert.deepEqual(buildProviderOptions(conn('stepfun-step-plan'), 'step-3.7-flash', 'medium'), { - 'stepfun-step-plan': { reasoningEffort: 'medium' }, + stepfunStepPlan: { reasoningEffort: 'medium' }, }); assert.deepEqual( buildProviderOptions(conn('stepfun-step-plan'), 'step-3.5-flash-2603', 'high'), - { 'stepfun-step-plan': { reasoningEffort: 'high' } }, + { stepfunStepPlan: { reasoningEffort: 'high' } }, ); assert.deepEqual( buildProviderOptions(conn('stepfun-step-plan'), 'step-3.5-flash-2603', 'medium'), @@ -329,13 +329,13 @@ describe('buildProviderOptions: thinking level', () => { ['off', 'minimal', 'low', 'medium', 'high'], ); assert.deepEqual(buildProviderOptions(conn('volcengine-ark'), modelId), { - 'volcengine-ark': { thinking: { type: 'enabled' } }, + volcengineArk: { thinking: { type: 'enabled' } }, }); assert.deepEqual(buildProviderOptions(conn('volcengine-ark'), modelId, 'high'), { - 'volcengine-ark': { thinking: { type: 'enabled' }, reasoningEffort: 'high' }, + volcengineArk: { thinking: { type: 'enabled' }, reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('volcengine-ark'), modelId, 'off'), { - 'volcengine-ark': { thinking: { type: 'disabled' } }, + volcengineArk: { thinking: { type: 'disabled' } }, }); }); @@ -369,7 +369,7 @@ describe('buildProviderOptions: thinking level', () => { ['low', 'medium', 'high'], ); assert.deepEqual(buildProviderOptions(conn('tencent-token-plan'), 'hy3', 'high'), { - 'tencent-token-plan': { reasoningEffort: 'high' }, + tencentTokenPlan: { reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('tencent-token-plan'), 'hy3', 'off'), {}); }); @@ -394,17 +394,17 @@ describe('buildProviderOptions: thinking level', () => { ['off', 'low', 'medium', 'high', 'max'], ); assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'glm-5.2', 'high'), { - 'ollama-cloud': { reasoningEffort: 'high' }, + ollamaCloud: { reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'glm-5.2', 'off'), { - 'ollama-cloud': { reasoningEffort: 'none' }, + ollamaCloud: { reasoningEffort: 'none' }, }); assert.deepEqual( [...thinkingVariantsForModel('ollama-cloud', 'gpt-oss:120b')], ['low', 'medium', 'high'], ); assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'gpt-oss:120b', 'high'), { - 'ollama-cloud': { reasoningEffort: 'high' }, + ollamaCloud: { reasoningEffort: 'high' }, }); assert.deepEqual(buildProviderOptions(conn('ollama-cloud'), 'gpt-oss:120b', 'off'), {}); }); @@ -490,14 +490,14 @@ describe('getAIModel: models.dev registry providers', () => { }); describe('buildProviderOptions: openai-compatible namespace', () => { - test('zai-coding-plan emits reasoningEffort under the raw dashed namespace', () => { + test('zai-coding-plan emits reasoningEffort under the camelCase namespace', () => { assert.deepEqual( buildProviderOptions(conn('zai-coding-plan', 'zai-coding-plan'), 'glm-5.2', 'high'), - { 'zai-coding-plan': { reasoningEffort: 'high' } }, + { zaiCodingPlan: { reasoningEffort: 'high' } }, ); assert.deepEqual( buildProviderOptions(conn('zai-coding-plan', 'zai-coding-plan'), 'glm-5.2', 'max'), - { 'zai-coding-plan': { reasoningEffort: 'max' } }, + { zaiCodingPlan: { reasoningEffort: 'max' } }, ); }); test('deepseek wires provider-native effort on both chat and Responses dialects', () => { @@ -599,4 +599,48 @@ describe('buildProviderOptions: openai-compatible namespace', () => { JSON.stringify(result.warnings), ); }); + + test('built-in dashed provider effort reaches the chat request body without deprecation', async () => { + // Built-in counterpart of the relay capture above: built-in dashed + // providerTypes must emit the SDK's camelCase alias too. + const bodies: Record[] = []; + const captureFetch: typeof globalThis.fetch = async (_input, init) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + object: 'chat.completion', + created: 1, + model: 'glm-5.2', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }; + const connection = conn('zai-coding-plan', 'zai-coding-plan'); + const model = getAIModel({ + connection, + apiKey: 'zai-key', + modelId: 'glm-5.2', + fetch: captureFetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(connection, 'glm-5.2', 'high'), + }); + assert.equal(bodies.length, 1); + assert.equal(bodies[0]?.reasoning_effort, 'high'); + assert.equal( + (result.warnings ?? []).some((warning) => warning.type === 'deprecated'), + false, + JSON.stringify(result.warnings), + ); + }); }); diff --git a/packages/runtime/src/__tests__/request-shape.test.ts b/packages/runtime/src/__tests__/request-shape.test.ts index 4a752b003f..4710c76fb9 100644 --- a/packages/runtime/src/__tests__/request-shape.test.ts +++ b/packages/runtime/src/__tests__/request-shape.test.ts @@ -247,8 +247,8 @@ describe('prepared provider request capture', () => { const openaiMax = hash({ kimiCodingPlan: { reasoningEffort: 'max' } }, 32_768); const nativeOpenaiMax = hash({ openai: { reasoningEffort: 'max' } }, 32_768); const nativeOpenaiHigh = hash({ openai: { reasoningEffort: 'high' } }, 32_768); - const zaiHigh = hash({ 'zai-coding-plan': { reasoningEffort: 'high' } }, 32_768); - const zaiLow = hash({ 'zai-coding-plan': { reasoningEffort: 'low' } }, 32_768); + const zaiHigh = hash({ zaiCodingPlan: { reasoningEffort: 'high' } }, 32_768); + const zaiLow = hash({ zaiCodingPlan: { reasoningEffort: 'low' } }, 32_768); assert.equal(anthropicMax, openaiMax); assert.equal(anthropicMax, nativeOpenaiMax); diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index f6dc871cfb..ce7f91afd3 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -468,7 +468,7 @@ export function buildProviderOptions( return buildFamilyWire(connection, modelId, level, thinkingOptions); case 'volcengine-ark': return { - [connection.providerType]: { + [toCamelCase(connection.providerType)]: { thinking: { type: level === 'off' ? 'disabled' : 'enabled' }, ...(level && level !== 'off' ? { reasoningEffort: level } : {}), }, @@ -495,7 +495,7 @@ export function buildProviderOptions( case 'cloudflare-workers-ai': return level ? { - [connection.providerType]: + [toCamelCase(connection.providerType)]: level === 'off' ? thinkingOptions?.offBehavior === 'cloudflare-chat-template-thinking-false' ? { chat_template_kwargs: { thinking: false } } @@ -584,7 +584,7 @@ function buildFamilyWire( if (copilotProtocol === 'anthropic-messages') { return level !== 'off' ? { anthropic: { effort: level } } : {}; } - return { 'github-copilot': { reasoningEffort } }; + return { githubCopilot: { reasoningEffort } }; } default: return {}; @@ -612,18 +612,15 @@ function toCamelCase(name: string): string { } /** - * The providerOptions key for an openai-compatible model. The SDK still - * accepts the raw provider name but flags dashed keys as deprecated (a - * `type: 'deprecated'` warning on every doGenerate result); its canonical - * key is the camelCase alias. Only the custom-relay path keys options by - * the connection slug, so only that path camelCases — built-in adapter - * namespaces stay as they were. + * The providerOptions key for an openai-compatible model: the camelCase + * alias of the identity passed to `createOpenAICompatible`. The SDK + * resolves both spellings — known options and passthrough fields alike — + * but flags dashed keys as deprecated (a `type: 'deprecated'` warning on + * every doGenerate result), so the camelCase alias is the canonical key. */ function openAiCompatibleProviderOptionsKey( adapter: ProviderRuntimeAdapter, connection: RuntimeExecutionConnection, ): string { - return adapter.kind === 'openai-compatible' && adapter.name === 'connection' - ? toCamelCase(connection.slug) - : connection.providerType; + return toCamelCase(openAiCompatibleProviderName(adapter, connection)); } From 4d98710ab60ac19bc74a1c847a756e4b149302cf Mon Sep 17 00:00:00 2001 From: Yihan Zhu <48186361+yihanzhu@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:59:48 -0400 Subject: [PATCH 2/2] test(runtime): pin the metadata namespace and the passthrough lane Address review: note in the providerOptions-key docstring that the camelCase alias also selects the SDK's response metadata namespace, assert the capture test's providerMetadata comes back under the camelCase key, and add a wire-level capture for volcengine-ark's passthrough thinking object - the lane the existing reasoningEffort captures do not exercise. Generated-by: Claude Code (Fable 5) Co-Authored-By: Claude Fable 5 --- .../__tests__/model-factory-thinking.test.ts | 48 +++++++++++++++++++ packages/runtime/src/model-factory.ts | 6 +++ 2 files changed, 54 insertions(+) diff --git a/packages/runtime/src/__tests__/model-factory-thinking.test.ts b/packages/runtime/src/__tests__/model-factory-thinking.test.ts index d91ed07e1f..55c117a6c1 100644 --- a/packages/runtime/src/__tests__/model-factory-thinking.test.ts +++ b/packages/runtime/src/__tests__/model-factory-thinking.test.ts @@ -642,5 +642,53 @@ describe('buildProviderOptions: openai-compatible namespace', () => { false, JSON.stringify(result.warnings), ); + // The options key also selects the SDK's response metadata namespace: + // metadata must come back under the camelCase alias, not the dashed name. + assert.deepEqual(Object.keys(result.providerMetadata ?? {}), ['zaiCodingPlan']); + }); + + test('passthrough provider options reach the chat request body without deprecation', async () => { + // reasoningEffort above travels the SDK's schema lane, which parses both + // spellings. Volcengine Ark's `thinking` object is not in the schema and + // travels the passthrough spread instead — pin that lane at the wire too. + const bodies: Record[] = []; + const captureFetch: typeof globalThis.fetch = async (_input, init) => { + bodies.push(JSON.parse(String(init?.body)) as Record); + return new Response( + JSON.stringify({ + id: 'chatcmpl-1', + object: 'chat.completion', + created: 1, + model: 'doubao-seed-2-0-pro-260215', + choices: [ + { + index: 0, + message: { role: 'assistant', content: 'ok' }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { status: 200, headers: { 'content-type': 'application/json' } }, + ); + }; + const connection = conn('volcengine-ark', 'volcengine-ark'); + const model = getAIModel({ + connection, + apiKey: 'ark-key', + modelId: 'doubao-seed-2-0-pro-260215', + fetch: captureFetch, + }); + const result = await model.doGenerate({ + prompt: [{ role: 'user', content: [{ type: 'text', text: 'hi' }] }], + providerOptions: buildProviderOptions(connection, 'doubao-seed-2-0-pro-260215'), + }); + assert.equal(bodies.length, 1); + assert.deepEqual(bodies[0]?.thinking, { type: 'enabled' }); + assert.equal( + (result.warnings ?? []).some((warning) => warning.type === 'deprecated'), + false, + JSON.stringify(result.warnings), + ); }); }); diff --git a/packages/runtime/src/model-factory.ts b/packages/runtime/src/model-factory.ts index ce7f91afd3..9f4fd1b9aa 100644 --- a/packages/runtime/src/model-factory.ts +++ b/packages/runtime/src/model-factory.ts @@ -617,6 +617,12 @@ function toCamelCase(name: string): string { * resolves both spellings — known options and passthrough fields alike — * but flags dashed keys as deprecated (a `type: 'deprecated'` warning on * every doGenerate result), so the camelCase alias is the canonical key. + * + * The same alias also selects the SDK's *response* metadata namespace: + * once options are keyed `zaiCodingPlan`, provider metadata comes back as + * `providerMetadata.zaiCodingPlan`, not `providerMetadata['zai-coding-plan']`. + * A metadata reader keyed by the raw `connection.providerType` would + * silently read nothing for dashed providers. */ function openAiCompatibleProviderOptionsKey( adapter: ProviderRuntimeAdapter,