Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 107 additions & 15 deletions packages/runtime/src/__tests__/model-factory-thinking.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
});
});

Expand Down Expand Up @@ -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'),
Expand All @@ -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' } },
});
});

Expand Down Expand Up @@ -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'), {});
});
Expand All @@ -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'), {});
});
Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -599,4 +599,96 @@ describe('buildProviderOptions: openai-compatible namespace', () => {
JSON.stringify(result.warnings),
);
});

test('built-in dashed provider effort reaches the chat request body without deprecation', async () => {
Comment thread
yihanzhu marked this conversation as resolved.
// Built-in counterpart of the relay capture above: built-in dashed
// providerTypes must emit the SDK's camelCase alias too.
const bodies: Record<string, unknown>[] = [];
const captureFetch: typeof globalThis.fetch = async (_input, init) => {
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
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),
);
// 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<string, unknown>[] = [];
const captureFetch: typeof globalThis.fetch = async (_input, init) => {
bodies.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
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),
);
});
});
4 changes: 2 additions & 2 deletions packages/runtime/src/__tests__/request-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
27 changes: 15 additions & 12 deletions packages/runtime/src/model-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
},
Expand All @@ -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 } }
Expand Down Expand Up @@ -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 {};
Expand Down Expand Up @@ -612,18 +612,21 @@ 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.
*
* 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,
connection: RuntimeExecutionConnection,
): string {
return adapter.kind === 'openai-compatible' && adapter.name === 'connection'
? toCamelCase(connection.slug)
: connection.providerType;
return toCamelCase(openAiCompatibleProviderName(adapter, connection));
Comment thread
yihanzhu marked this conversation as resolved.
}