diff --git a/.changeset/real-rules-kiss.md b/.changeset/real-rules-kiss.md new file mode 100644 index 000000000000..77bd3b5043ef --- /dev/null +++ b/.changeset/real-rules-kiss.md @@ -0,0 +1,15 @@ +--- +'ai': patch +'@ai-sdk/provider': patch +'@ai-sdk/openai': patch +'@ai-sdk/openai-compatible': patch +'@ai-sdk/anthropic': patch +--- + +Add top-level `thinking` call settings to the V3 language model spec and map them across providers. + +- Added `thinking` to `LanguageModelV3CallOptions` and AI SDK call settings with runtime validation. +- Forwarded `thinking` through `generateText`, `streamText`, `generateObject`, `streamObject`, and `ToolLoopAgent`. +- Mapped top-level `thinking` to OpenAI chat/responses and OpenAI-compatible `reasoning_effort`. +- Mapped top-level `thinking` to Anthropic thinking configuration and effort output settings. +- Added regression tests for forwarding, precedence, and unsupported budget warnings. diff --git a/content/docs/07-reference/01-ai-sdk-core/01-generate-text.mdx b/content/docs/07-reference/01-ai-sdk-core/01-generate-text.mdx index f8793423e981..97cadcfc2c0d 100644 --- a/content/docs/07-reference/01-ai-sdk-core/01-generate-text.mdx +++ b/content/docs/07-reference/01-ai-sdk-core/01-generate-text.mdx @@ -421,6 +421,13 @@ To see `generateText` in action, check out [these examples](#examples). description: 'The seed (integer) to use for random sampling. If set and supported by the model, calls will generate deterministic results.', }, + { + name: 'thinking', + type: '{ type: "enabled"; effort?: "low" | "medium" | "high"; budgetTokens?: number } | { type: "disabled" }', + isOptional: true, + description: + 'Top-level thinking / reasoning configuration. Providers may map this to provider-specific reasoning settings.', + }, { name: 'maxRetries', type: 'number', diff --git a/content/docs/07-reference/01-ai-sdk-core/02-stream-text.mdx b/content/docs/07-reference/01-ai-sdk-core/02-stream-text.mdx index 350da69a5777..e82306c7cc84 100644 --- a/content/docs/07-reference/01-ai-sdk-core/02-stream-text.mdx +++ b/content/docs/07-reference/01-ai-sdk-core/02-stream-text.mdx @@ -422,6 +422,13 @@ To see `streamText` in action, check out [these examples](#examples). description: 'The seed (integer) to use for random sampling. If set and supported by the model, calls will generate deterministic results.', }, + { + name: 'thinking', + type: '{ type: "enabled"; effort?: "low" | "medium" | "high"; budgetTokens?: number } | { type: "disabled" }', + isOptional: true, + description: + 'Top-level thinking / reasoning configuration. Providers may map this to provider-specific reasoning settings.', + }, { name: 'maxRetries', type: 'number', diff --git a/content/docs/07-reference/01-ai-sdk-core/03-generate-object.mdx b/content/docs/07-reference/01-ai-sdk-core/03-generate-object.mdx index 23446702e015..228d12ffe114 100644 --- a/content/docs/07-reference/01-ai-sdk-core/03-generate-object.mdx +++ b/content/docs/07-reference/01-ai-sdk-core/03-generate-object.mdx @@ -459,6 +459,13 @@ To see `generateObject` in action, check out the [additional examples](#more-exa description: 'The seed (integer) to use for random sampling. If set and supported by the model, calls will generate deterministic results.', }, + { + name: 'thinking', + type: '{ type: "enabled"; effort?: "low" | "medium" | "high"; budgetTokens?: number } | { type: "disabled" }', + isOptional: true, + description: + 'Top-level thinking / reasoning configuration. Providers may map this to provider-specific reasoning settings.', + }, { name: 'maxRetries', type: 'number', diff --git a/content/docs/07-reference/01-ai-sdk-core/04-stream-object.mdx b/content/docs/07-reference/01-ai-sdk-core/04-stream-object.mdx index b163849bfbc8..3eeb4ef33d2c 100644 --- a/content/docs/07-reference/01-ai-sdk-core/04-stream-object.mdx +++ b/content/docs/07-reference/01-ai-sdk-core/04-stream-object.mdx @@ -465,6 +465,13 @@ To see `streamObject` in action, check out the [additional examples](#more-examp description: 'The seed (integer) to use for random sampling. If set and supported by the model, calls will generate deterministic results.', }, + { + name: 'thinking', + type: '{ type: "enabled"; effort?: "low" | "medium" | "high"; budgetTokens?: number } | { type: "disabled" }', + isOptional: true, + description: + 'Top-level thinking / reasoning configuration. Providers may map this to provider-specific reasoning settings.', + }, { name: 'maxRetries', type: 'number', diff --git a/content/providers/01-ai-sdk-providers/03-openai.mdx b/content/providers/01-ai-sdk-providers/03-openai.mdx index 8551a5823d2f..154c7cd57c2b 100644 --- a/content/providers/01-ai-sdk-providers/03-openai.mdx +++ b/content/providers/01-ai-sdk-providers/03-openai.mdx @@ -193,7 +193,9 @@ The following provider options are available: A unique identifier representing your end-user, which can help OpenAI to monitor and detect abuse. Defaults to `undefined`. - **reasoningEffort** _'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh'_ - Reasoning effort for reasoning models. Defaults to `medium`. If you use `providerOptions` to set the `reasoningEffort` option, this model setting will be ignored. + Reasoning effort for reasoning models. Defaults to `medium`. + You can also set top-level `thinking` in `generateText`, `streamText`, `generateObject`, or `streamObject`. + If both are set, top-level `thinking` takes precedence. The 'none' type for `reasoningEffort` is only available for OpenAI's GPT-5.1 diff --git a/content/providers/01-ai-sdk-providers/05-anthropic.mdx b/content/providers/01-ai-sdk-providers/05-anthropic.mdx index 895397fe5ec8..511228e3d18f 100644 --- a/content/providers/01-ai-sdk-providers/05-anthropic.mdx +++ b/content/providers/01-ai-sdk-providers/05-anthropic.mdx @@ -221,20 +221,18 @@ The `speed` option accepts `'fast'` or `'standard'` (default behavior). Anthropic has reasoning support for `claude-opus-4-20250514`, `claude-sonnet-4-20250514`, and `claude-3-7-sonnet-20250219` models. -You can enable it using the `thinking` provider option -and specifying a thinking budget in tokens. +You can enable it with top-level `thinking` and optionally specify a token budget. -```ts highlight="4,8-10" -import { anthropic, AnthropicLanguageModelOptions } from '@ai-sdk/anthropic'; +```ts highlight="4,7-10" +import { anthropic } from '@ai-sdk/anthropic'; import { generateText } from 'ai'; const { text, reasoningText, reasoning } = await generateText({ model: anthropic('claude-opus-4-20250514'), prompt: 'How many people will live in the world in 2040?', - providerOptions: { - anthropic: { - thinking: { type: 'enabled', budgetTokens: 12000 }, - } satisfies AnthropicLanguageModelOptions, + thinking: { + type: 'enabled', + budgetTokens: 12000, }, }); diff --git a/packages/ai/src/agent/tool-loop-agent-settings.ts b/packages/ai/src/agent/tool-loop-agent-settings.ts index d9b601dc1109..57ccd6247dac 100644 --- a/packages/ai/src/agent/tool-loop-agent-settings.ts +++ b/packages/ai/src/agent/tool-loop-agent-settings.ts @@ -149,6 +149,7 @@ export type ToolLoopAgentSettings< | 'frequencyPenalty' | 'stopSequences' | 'seed' + | 'thinking' | 'headers' | 'instructions' | 'stopWhen' @@ -171,6 +172,7 @@ export type ToolLoopAgentSettings< | 'frequencyPenalty' | 'stopSequences' | 'seed' + | 'thinking' | 'headers' | 'instructions' | 'stopWhen' diff --git a/packages/ai/src/agent/tool-loop-agent.test.ts b/packages/ai/src/agent/tool-loop-agent.test.ts index 0e5080bd506f..1182a4767d52 100644 --- a/packages/ai/src/agent/tool-loop-agent.test.ts +++ b/packages/ai/src/agent/tool-loop-agent.test.ts @@ -77,6 +77,22 @@ describe('ToolLoopAgent', () => { expect(doGenerateOptions?.abortSignal).toBe(abortController.signal); }); + it('should pass thinking to generateText', async () => { + const agent = new ToolLoopAgent({ + model: mockModel, + thinking: { type: 'enabled', effort: 'high' }, + }); + + await agent.generate({ + prompt: 'Hello, world!', + }); + + expect(doGenerateOptions?.thinking).toEqual({ + type: 'enabled', + effort: 'high', + }); + }); + it('should pass timeout to generateText', async () => { const agent = new ToolLoopAgent({ model: mockModel }); @@ -350,6 +366,24 @@ describe('ToolLoopAgent', () => { expect(doStreamOptions?.abortSignal).toBe(abortController.signal); }); + it('should pass thinking to streamText', async () => { + const agent = new ToolLoopAgent({ + model: mockModel, + thinking: { type: 'enabled', effort: 'medium' }, + }); + + const result = await agent.stream({ + prompt: 'Hello, world!', + }); + + await result.consumeStream(); + + expect(doStreamOptions?.thinking).toEqual({ + type: 'enabled', + effort: 'medium', + }); + }); + it('should pass timeout to streamText', async () => { const agent = new ToolLoopAgent({ model: mockModel, diff --git a/packages/ai/src/generate-object/generate-object.test.ts b/packages/ai/src/generate-object/generate-object.test.ts index 97bf2012e268..3af8f97bbf83 100644 --- a/packages/ai/src/generate-object/generate-object.test.ts +++ b/packages/ai/src/generate-object/generate-object.test.ts @@ -701,6 +701,41 @@ describe('generateObject', () => { }); }); + describe('options.thinking', () => { + it('should pass thinking settings to model', async () => { + const result = await generateObject({ + model: new MockLanguageModelV3({ + doGenerate: async ({ thinking }) => { + expect(thinking).toStrictEqual({ + type: 'enabled', + effort: 'low', + }); + + return { + ...dummyResponseValues, + content: [ + { + type: 'text', + text: '{ "content": "thinking settings test" }', + }, + ], + }; + }, + }), + schema: z.object({ content: z.string() }), + prompt: 'prompt', + thinking: { + type: 'enabled', + effort: 'low', + }, + }); + + expect(result.object).toStrictEqual({ + content: 'thinking settings test', + }); + }); + }); + describe('error handling', () => { function verifyNoObjectGeneratedError( error: unknown, diff --git a/packages/ai/src/generate-object/generate-object.ts b/packages/ai/src/generate-object/generate-object.ts index 7986ffa9a99b..ac9e5196e530 100644 --- a/packages/ai/src/generate-object/generate-object.ts +++ b/packages/ai/src/generate-object/generate-object.ts @@ -76,6 +76,7 @@ const originalGenerateId = createIdGenerator({ prefix: 'aiobj', size: 24 }); * If set, the model will stop generating text when one of the stop sequences is generated. * @param seed - The seed (integer) to use for random sampling. * If set and supported by the model, calls will generate deterministic results. + * @param thinking - Top-level thinking / reasoning configuration. * * @param maxRetries - Maximum number of retries. Set to 0 to disable retries. Default: 2. * @param abortSignal - An optional abort signal that can be used to cancel the call. diff --git a/packages/ai/src/generate-object/stream-object.test.ts b/packages/ai/src/generate-object/stream-object.test.ts index 6fb0789dd2f6..4d825499e8b7 100644 --- a/packages/ai/src/generate-object/stream-object.test.ts +++ b/packages/ai/src/generate-object/stream-object.test.ts @@ -802,6 +802,48 @@ describe('streamObject', () => { }); }); + describe('options.thinking', () => { + it('should pass thinking settings to model', async () => { + const result = streamObject({ + model: new MockLanguageModelV3({ + doStream: async ({ thinking }) => { + expect(thinking).toStrictEqual({ + type: 'enabled', + effort: 'high', + }); + + return { + stream: convertArrayToReadableStream([ + { type: 'text-start', id: '1' }, + { + type: 'text-delta', + id: '1', + delta: `{ "content": "thinking settings test" }`, + }, + { type: 'text-end', id: '1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: testUsage, + }, + ]), + }; + }, + }), + schema: z.object({ content: z.string() }), + prompt: 'prompt', + thinking: { + type: 'enabled', + effort: 'high', + }, + }); + + expect( + await convertAsyncIterableToArray(result.partialObjectStream), + ).toStrictEqual([{ content: 'thinking settings test' }]); + }); + }); + describe('custom schema', () => { it('should send object deltas', async () => { const mockModel = createTestModel(); diff --git a/packages/ai/src/generate-object/stream-object.ts b/packages/ai/src/generate-object/stream-object.ts index 15788bdf54ea..34ce8112e7eb 100644 --- a/packages/ai/src/generate-object/stream-object.ts +++ b/packages/ai/src/generate-object/stream-object.ts @@ -140,6 +140,7 @@ export type StreamObjectOnFinishCallback = (event: { * If set, the model will stop generating text when one of the stop sequences is generated. * @param seed - The seed (integer) to use for random sampling. * If set and supported by the model, calls will generate deterministic results. + * @param thinking - Top-level thinking / reasoning configuration. * * @param maxRetries - Maximum number of retries. Set to 0 to disable retries. Default: 2. * @param abortSignal - An optional abort signal that can be used to cancel the call. diff --git a/packages/ai/src/generate-text/generate-text.test.ts b/packages/ai/src/generate-text/generate-text.test.ts index bf012adb3342..7d69c104eab4 100644 --- a/packages/ai/src/generate-text/generate-text.test.ts +++ b/packages/ai/src/generate-text/generate-text.test.ts @@ -2235,6 +2235,33 @@ describe('generateText', () => { }); }); + describe('options.thinking', () => { + it('should pass thinking settings to model', async () => { + const result = await generateText({ + model: new MockLanguageModelV3({ + doGenerate: async ({ thinking }) => { + expect(thinking).toStrictEqual({ + type: 'enabled', + effort: 'high', + }); + + return { + ...dummyResponseValues, + content: [{ type: 'text', text: 'thinking settings test' }], + }; + }, + }), + prompt: 'test-input', + thinking: { + type: 'enabled', + effort: 'high', + }, + }); + + expect(result.text).toStrictEqual('thinking settings test'); + }); + }); + describe('options.abortSignal', () => { it('should forward abort signal to tool execution', async () => { const abortController = new AbortController(); diff --git a/packages/ai/src/generate-text/generate-text.ts b/packages/ai/src/generate-text/generate-text.ts index 6f5d345be439..4b45da36ff7c 100644 --- a/packages/ai/src/generate-text/generate-text.ts +++ b/packages/ai/src/generate-text/generate-text.ts @@ -156,6 +156,7 @@ export type GenerateTextOnFinishCallback = ( * If set, the model will stop generating text when one of the stop sequences is generated. * @param seed - The seed (integer) to use for random sampling. * If set and supported by the model, calls will generate deterministic results. + * @param thinking - Top-level thinking / reasoning configuration. * * @param maxRetries - Maximum number of retries. Set to 0 to disable retries. Default: 2. * @param abortSignal - An optional abort signal that can be used to cancel the call. diff --git a/packages/ai/src/generate-text/stream-text.test.ts b/packages/ai/src/generate-text/stream-text.test.ts index 01a795679518..eb0f7e9a4324 100644 --- a/packages/ai/src/generate-text/stream-text.test.ts +++ b/packages/ai/src/generate-text/stream-text.test.ts @@ -10059,6 +10059,49 @@ describe('streamText', () => { }); }); + describe('options.thinking', () => { + it('should pass thinking settings to model', async () => { + const result = streamText({ + model: new MockLanguageModelV3({ + doStream: async ({ thinking }) => { + expect(thinking).toStrictEqual({ + type: 'enabled', + effort: 'medium', + }); + + return { + stream: convertArrayToReadableStream([ + { type: 'text-start', id: '1' }, + { + type: 'text-delta', + id: '1', + delta: 'thinking settings test', + }, + { type: 'text-end', id: '1' }, + { + type: 'finish', + finishReason: { unified: 'stop', raw: 'stop' }, + usage: testUsage, + }, + ]), + }; + }, + }), + prompt: 'test-input', + thinking: { + type: 'enabled', + effort: 'medium', + }, + onError: () => {}, + }); + + assert.deepStrictEqual( + await convertAsyncIterableToArray(result.textStream), + ['thinking settings test'], + ); + }); + }); + describe('options.abortSignal', () => { it('should forward abort signal to tool execution during streaming', async () => { const abortController = new AbortController(); diff --git a/packages/ai/src/generate-text/stream-text.ts b/packages/ai/src/generate-text/stream-text.ts index 1e8524125604..87836b66b298 100644 --- a/packages/ai/src/generate-text/stream-text.ts +++ b/packages/ai/src/generate-text/stream-text.ts @@ -238,6 +238,7 @@ export type StreamTextOnAbortCallback = (event: { * If set, the model will stop generating text when one of the stop sequences is generated. * @param seed - The seed (integer) to use for random sampling. * If set and supported by the model, calls will generate deterministic results. + * @param thinking - Top-level thinking / reasoning configuration. * * @param maxRetries - Maximum number of retries. Set to 0 to disable retries. Default: 2. * @param abortSignal - An optional abort signal that can be used to cancel the call. diff --git a/packages/ai/src/middleware/default-settings-middleware.test.ts b/packages/ai/src/middleware/default-settings-middleware.test.ts index 78d7f151f7d5..1c162f4bb21e 100644 --- a/packages/ai/src/middleware/default-settings-middleware.test.ts +++ b/packages/ai/src/middleware/default-settings-middleware.test.ts @@ -236,6 +236,33 @@ describe('defaultSettingsMiddleware', () => { expect(result.maxOutputTokens).toBe(50); }); + it('should apply default thinking', async () => { + const middleware = defaultSettingsMiddleware({ + settings: { thinking: { type: 'enabled', effort: 'high' } }, + }); + const result = await middleware.transformParams!({ + type: 'generate', + params: BASE_PARAMS, + model: MOCK_MODEL, + }); + expect(result.thinking).toEqual({ type: 'enabled', effort: 'high' }); + }); + + it('should prioritize param thinking', async () => { + const middleware = defaultSettingsMiddleware({ + settings: { thinking: { type: 'enabled', effort: 'high' } }, + }); + const result = await middleware.transformParams!({ + type: 'generate', + params: { + ...BASE_PARAMS, + thinking: { type: 'disabled' }, + }, + model: MOCK_MODEL, + }); + expect(result.thinking).toEqual({ type: 'disabled' }); + }); + it('should apply default stopSequences', async () => { const middleware = defaultSettingsMiddleware({ settings: { stopSequences: ['stop'] }, diff --git a/packages/ai/src/middleware/default-settings-middleware.ts b/packages/ai/src/middleware/default-settings-middleware.ts index 980816dcf186..82696ecbb925 100644 --- a/packages/ai/src/middleware/default-settings-middleware.ts +++ b/packages/ai/src/middleware/default-settings-middleware.ts @@ -18,6 +18,7 @@ export function defaultSettingsMiddleware({ frequencyPenalty?: LanguageModelV3CallOptions['frequencyPenalty']; responseFormat?: LanguageModelV3CallOptions['responseFormat']; seed?: LanguageModelV3CallOptions['seed']; + thinking?: LanguageModelV3CallOptions['thinking']; tools?: LanguageModelV3CallOptions['tools']; toolChoice?: LanguageModelV3CallOptions['toolChoice']; headers?: LanguageModelV3CallOptions['headers']; @@ -27,7 +28,17 @@ export function defaultSettingsMiddleware({ return { specificationVersion: 'v3', transformParams: async ({ params }) => { - return mergeObjects(settings, params) as LanguageModelV3CallOptions; + const mergedParams = mergeObjects( + settings, + params, + ) as LanguageModelV3CallOptions; + + // `thinking` is a discriminated union and must be replaced atomically. + if (params.thinking != null) { + mergedParams.thinking = params.thinking; + } + + return mergedParams; }, }; } diff --git a/packages/ai/src/prompt/call-settings.ts b/packages/ai/src/prompt/call-settings.ts index fe9b29c60cc0..764e5b4ddef2 100644 --- a/packages/ai/src/prompt/call-settings.ts +++ b/packages/ai/src/prompt/call-settings.ts @@ -1,3 +1,7 @@ +import { LanguageModelV3CallOptions } from '@ai-sdk/provider'; + +type ThinkingCallSettings = LanguageModelV3CallOptions['thinking']; + /** * Timeout configuration for API calls. Can be specified as: * - A number representing milliseconds @@ -120,6 +124,11 @@ export type CallSettings = { */ seed?: number; + /** + * Top-level thinking / reasoning configuration. + */ + thinking?: ThinkingCallSettings; + /** * Maximum number of retries. Set to 0 to disable retries. * diff --git a/packages/ai/src/prompt/prepare-call-settings.test.ts b/packages/ai/src/prompt/prepare-call-settings.test.ts index ad86e205a87d..8b140be93a83 100644 --- a/packages/ai/src/prompt/prepare-call-settings.test.ts +++ b/packages/ai/src/prompt/prepare-call-settings.test.ts @@ -28,10 +28,31 @@ describe('prepareCallSettings', () => { presencePenalty: undefined, frequencyPenalty: undefined, seed: undefined, + thinking: undefined, }; expect(() => prepareCallSettings(validSettings)).not.toThrow(); }); + + it('should allow valid thinking settings', () => { + expect(() => + prepareCallSettings({ + thinking: { + type: 'enabled', + effort: 'high', + budgetTokens: 2048, + }, + }), + ).not.toThrow(); + + expect(() => + prepareCallSettings({ + thinking: { + type: 'disabled', + }, + }), + ).not.toThrow(); + }); }); describe('invalid inputs', () => { @@ -134,6 +155,102 @@ describe('prepareCallSettings', () => { ); }); }); + + describe('thinking', () => { + it('should throw InvalidArgumentError if thinking is not an object', () => { + expect(() => + prepareCallSettings({ thinking: 'enabled' as any }), + ).toThrow( + new InvalidArgumentError({ + parameter: 'thinking', + value: 'enabled', + message: 'thinking must be an object', + }), + ); + }); + + it('should throw InvalidArgumentError if thinking.type is invalid', () => { + expect(() => + prepareCallSettings({ thinking: { type: 'adaptive' } as any }), + ).toThrow( + new InvalidArgumentError({ + parameter: 'thinking.type', + value: 'adaptive', + message: 'thinking.type must be "enabled" or "disabled"', + }), + ); + }); + + it('should throw InvalidArgumentError if thinking.effort is invalid', () => { + expect(() => + prepareCallSettings({ + thinking: { + type: 'enabled', + effort: 'xhigh', + } as any, + }), + ).toThrow( + new InvalidArgumentError({ + parameter: 'thinking.effort', + value: 'xhigh', + message: 'thinking.effort must be "low", "medium", or "high"', + }), + ); + }); + + it('should throw InvalidArgumentError if thinking.budgetTokens is invalid', () => { + expect(() => + prepareCallSettings({ + thinking: { + type: 'enabled', + budgetTokens: 0, + }, + }), + ).toThrow( + new InvalidArgumentError({ + parameter: 'thinking.budgetTokens', + value: 0, + message: 'thinking.budgetTokens must be an integer >= 1', + }), + ); + }); + + it('should throw InvalidArgumentError if thinking.effort is set while disabled', () => { + expect(() => + prepareCallSettings({ + thinking: { + type: 'disabled', + effort: 'high', + } as any, + }), + ).toThrow( + new InvalidArgumentError({ + parameter: 'thinking.effort', + value: 'high', + message: + 'thinking.effort can only be set when thinking.type is "enabled"', + }), + ); + }); + + it('should throw InvalidArgumentError if thinking.budgetTokens is set while disabled', () => { + expect(() => + prepareCallSettings({ + thinking: { + type: 'disabled', + budgetTokens: 1024, + } as any, + }), + ).toThrow( + new InvalidArgumentError({ + parameter: 'thinking.budgetTokens', + value: 1024, + message: + 'thinking.budgetTokens can only be set when thinking.type is "enabled"', + }), + ); + }); + }); }); it('should return a new object with limited values', () => { diff --git a/packages/ai/src/prompt/prepare-call-settings.ts b/packages/ai/src/prompt/prepare-call-settings.ts index 499930175397..5c48baf4b8c1 100644 --- a/packages/ai/src/prompt/prepare-call-settings.ts +++ b/packages/ai/src/prompt/prepare-call-settings.ts @@ -13,6 +13,7 @@ export function prepareCallSettings({ frequencyPenalty, seed, stopSequences, + thinking, }: Omit): Omit< CallSettings, 'abortSignal' | 'headers' | 'maxRetries' @@ -95,6 +96,75 @@ export function prepareCallSettings({ } } + if (thinking != null) { + if (typeof thinking !== 'object' || Array.isArray(thinking)) { + throw new InvalidArgumentError({ + parameter: 'thinking', + value: thinking, + message: 'thinking must be an object', + }); + } + + const thinkingType = thinking.type; + if (thinkingType !== 'enabled' && thinkingType !== 'disabled') { + throw new InvalidArgumentError({ + parameter: 'thinking.type', + value: thinkingType, + message: 'thinking.type must be "enabled" or "disabled"', + }); + } + + const effort = (thinking as { effort?: unknown }).effort; + const budgetTokens = (thinking as { budgetTokens?: unknown }).budgetTokens; + + if (thinkingType === 'enabled') { + if ( + effort != null && + effort !== 'low' && + effort !== 'medium' && + effort !== 'high' + ) { + throw new InvalidArgumentError({ + parameter: 'thinking.effort', + value: effort, + message: 'thinking.effort must be "low", "medium", or "high"', + }); + } + + if (budgetTokens != null) { + if ( + typeof budgetTokens !== 'number' || + !Number.isInteger(budgetTokens) || + budgetTokens < 1 + ) { + throw new InvalidArgumentError({ + parameter: 'thinking.budgetTokens', + value: budgetTokens, + message: 'thinking.budgetTokens must be an integer >= 1', + }); + } + } + } else { + if (effort != null) { + throw new InvalidArgumentError({ + parameter: 'thinking.effort', + value: effort, + message: + 'thinking.effort can only be set when thinking.type is "enabled"', + }); + } + + if (budgetTokens != null) { + throw new InvalidArgumentError({ + parameter: 'thinking.budgetTokens', + value: budgetTokens, + message: + 'thinking.budgetTokens can only be set when thinking.type is "enabled"', + }); + } + } + } + return { maxOutputTokens, temperature, @@ -104,5 +174,6 @@ export function prepareCallSettings({ frequencyPenalty, stopSequences, seed, + ...(thinking != null && { thinking }), }; } diff --git a/packages/ai/src/telemetry/get-base-telemetry-attributes.ts b/packages/ai/src/telemetry/get-base-telemetry-attributes.ts index c45928e91daa..2d521c7805f0 100644 --- a/packages/ai/src/telemetry/get-base-telemetry-attributes.ts +++ b/packages/ai/src/telemetry/get-base-telemetry-attributes.ts @@ -27,6 +27,12 @@ export function getBaseTelemetryAttributes({ if (totalTimeoutMs != null) { attributes[`ai.settings.${key}`] = totalTimeoutMs; } + } else if ( + value != null && + typeof value === 'object' && + !Array.isArray(value) + ) { + attributes[`ai.settings.${key}`] = JSON.stringify(value); } else { attributes[`ai.settings.${key}`] = value as AttributeValue; } diff --git a/packages/anthropic/src/anthropic-messages-language-model.test.ts b/packages/anthropic/src/anthropic-messages-language-model.test.ts index 48fd623d2fe0..2ca7baab87bb 100644 --- a/packages/anthropic/src/anthropic-messages-language-model.test.ts +++ b/packages/anthropic/src/anthropic-messages-language-model.test.ts @@ -119,6 +119,60 @@ describe('AnthropicMessagesLanguageModel', () => { `); }); + it('should pass top-level thinking and effort settings', async () => { + prepareJsonFixtureResponse('anthropic-text'); + + const result = await provider('claude-sonnet-4-5').doGenerate({ + prompt: TEST_PROMPT, + thinking: { + type: 'enabled', + effort: 'low', + budgetTokens: 2048, + }, + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + thinking: { + type: 'enabled', + budget_tokens: 2048, + }, + output_config: { + effort: 'low', + }, + }); + + expect(result.warnings).toEqual([]); + }); + + it('should prioritize top-level thinking over providerOptions thinking', async () => { + prepareJsonFixtureResponse('anthropic-text'); + + await provider('claude-sonnet-4-5').doGenerate({ + prompt: TEST_PROMPT, + thinking: { + type: 'enabled', + effort: 'high', + budgetTokens: 1500, + }, + providerOptions: { + anthropic: { + thinking: { type: 'disabled' }, + effort: 'low', + } satisfies AnthropicLanguageModelOptions, + }, + }); + + expect(await server.calls[0].requestBodyJson).toMatchObject({ + thinking: { + type: 'enabled', + budget_tokens: 1500, + }, + output_config: { + effort: 'high', + }, + }); + }); + it('should extract reasoning response', async () => { server.urls['https://api.anthropic.com/v1/messages'].response = { type: 'json-value', diff --git a/packages/anthropic/src/anthropic-messages-language-model.ts b/packages/anthropic/src/anthropic-messages-language-model.ts index a5dfbb7457d0..cc5c6c8acf03 100644 --- a/packages/anthropic/src/anthropic-messages-language-model.ts +++ b/packages/anthropic/src/anthropic-messages-language-model.ts @@ -181,6 +181,7 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 { tools, toolChoice, providerOptions, + thinking, stream, }: LanguageModelV3CallOptions & { stream: boolean; @@ -255,6 +256,21 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 { canonicalOptions ?? {}, customProviderOptions ?? {}, ); + const effectiveThinking = + thinking == null + ? anthropicOptions?.thinking + : thinking.type === 'enabled' + ? { + type: 'enabled', + ...(thinking.budgetTokens != null && { + budgetTokens: thinking.budgetTokens, + }), + } + : { type: 'disabled' as const }; + const effectiveEffort = + thinking?.type === 'enabled' && thinking.effort != null + ? thinking.effort + : anthropicOptions?.effort; const { maxOutputTokens: maxOutputTokensForModel, @@ -319,13 +335,11 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 { toolNameMapping, }); - const thinkingType = anthropicOptions?.thinking?.type; + const thinkingType = effectiveThinking?.type; const isThinking = thinkingType === 'enabled' || thinkingType === 'adaptive'; let thinkingBudget = - thinkingType === 'enabled' - ? anthropicOptions?.thinking?.budgetTokens - : undefined; + thinkingType === 'enabled' ? effectiveThinking?.budgetTokens : undefined; const maxTokens = maxOutputTokens ?? maxOutputTokensForModel; @@ -347,8 +361,8 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 { ...(thinkingBudget != null && { budget_tokens: thinkingBudget }), }, }), - ...(anthropicOptions?.effort && { - output_config: { effort: anthropicOptions.effort }, + ...(effectiveEffort && { + output_config: { effort: effectiveEffort }, }), ...(anthropicOptions?.speed && { speed: anthropicOptions.speed, @@ -572,7 +586,7 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 { } } - if (anthropicOptions?.effort) { + if (effectiveEffort) { betas.add('effort-2025-11-24'); } 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 ee71b886e191..c025adce134e 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 @@ -974,6 +974,128 @@ describe('doGenerate', () => { `); }); + it('should pass top-level thinking effort', async () => { + prepareJsonResponse({ content: '{"value":"test"}' }); + + const model = new OpenAICompatibleChatLanguageModel('gpt-5', { + provider: 'test-provider', + url: () => 'https://my.api.com/v1/chat/completions', + headers: () => ({}), + }); + + await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'enabled', effort: 'low' }, + }); + + expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(` + { + "messages": [ + { + "content": "Hello", + "role": "user", + }, + ], + "model": "gpt-5", + "reasoning_effort": "low", + } + `); + }); + + it('should allow disabling thinking via top-level settings', async () => { + prepareJsonResponse({ content: '{"value":"test"}' }); + + const model = new OpenAICompatibleChatLanguageModel('gpt-5', { + provider: 'test-provider', + url: () => 'https://my.api.com/v1/chat/completions', + headers: () => ({}), + }); + + await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'disabled' }, + }); + + expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(` + { + "messages": [ + { + "content": "Hello", + "role": "user", + }, + ], + "model": "gpt-5", + "reasoning_effort": "none", + } + `); + }); + + it('should prioritize top-level thinking over providerOptions reasoningEffort', async () => { + prepareJsonResponse({ content: '{"value":"test"}' }); + + const model = new OpenAICompatibleChatLanguageModel('gpt-5', { + provider: 'test-provider', + url: () => 'https://my.api.com/v1/chat/completions', + headers: () => ({}), + }); + + await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'enabled', effort: 'medium' }, + providerOptions: { + 'test-provider': { reasoningEffort: 'high' }, + }, + }); + + expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(` + { + "messages": [ + { + "content": "Hello", + "role": "user", + }, + ], + "model": "gpt-5", + "reasoning_effort": "medium", + } + `); + }); + + it('should warn when thinking.budgetTokens is provided', async () => { + prepareJsonResponse({ content: '{"value":"test"}' }); + + const model = new OpenAICompatibleChatLanguageModel('gpt-5', { + provider: 'test-provider', + url: () => 'https://my.api.com/v1/chat/completions', + headers: () => ({}), + }); + + const { warnings } = await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'enabled', effort: 'high', budgetTokens: 1024 }, + }); + + expect(await server.calls[0].requestBodyJson).toMatchInlineSnapshot(` + { + "messages": [ + { + "content": "Hello", + "role": "user", + }, + ], + "model": "gpt-5", + "reasoning_effort": "high", + } + `); + + expect(warnings).toContainEqual({ + type: 'unsupported', + feature: 'thinking.budgetTokens', + details: + 'thinking.budgetTokens is not supported by OpenAI-compatible chat APIs', + }); + }); + it('should not duplicate reasoningEffort in request body', async () => { prepareJsonResponse({ content: '{"value":"test"}' }); 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 9a803cb1173e..767b4aca7d1b 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 @@ -67,6 +67,20 @@ export type OpenAICompatibleChatConfig = { transformRequestBody?: (args: Record) => Record; }; +function mapThinkingToOpenAICompatibleReasoningEffort( + thinking: LanguageModelV3CallOptions['thinking'] | undefined, +) { + if (thinking == null) { + return undefined; + } + + if (thinking.type === 'disabled') { + return 'none'; + } + + return thinking.effort; +} + export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { readonly specificationVersion = 'v3'; @@ -125,6 +139,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { seed, toolChoice, tools, + thinking, }: LanguageModelV3CallOptions) { const warnings: SharedV3Warning[] = []; @@ -157,6 +172,19 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { ); const strictJsonSchema = compatibleOptions?.strictJsonSchema ?? true; + const reasoningEffort = + thinking == null + ? compatibleOptions.reasoningEffort + : mapThinkingToOpenAICompatibleReasoningEffort(thinking); + + if (thinking?.type === 'enabled' && thinking.budgetTokens != null) { + warnings.push({ + type: 'unsupported', + feature: 'thinking.budgetTokens', + details: + 'thinking.budgetTokens is not supported by OpenAI-compatible chat APIs', + }); + } if (topK != null) { warnings.push({ type: 'unsupported', feature: 'topK' }); @@ -227,7 +255,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { ), ), - reasoning_effort: compatibleOptions.reasoningEffort, + reasoning_effort: reasoningEffort, verbosity: compatibleOptions.textVerbosity, // messages: diff --git a/packages/openai/src/chat/openai-chat-language-model.test.ts b/packages/openai/src/chat/openai-chat-language-model.test.ts index 51ff1070b642..efecabb1b79c 100644 --- a/packages/openai/src/chat/openai-chat-language-model.test.ts +++ b/packages/openai/src/chat/openai-chat-language-model.test.ts @@ -657,6 +657,83 @@ describe('doGenerate', () => { }); }); + it('should pass thinking effort from top-level settings', async () => { + prepareJsonFixtureResponse('openai-text'); + + const model = provider.chat('o4-mini'); + + await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'enabled', effort: 'high' }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: 'o4-mini', + messages: [{ role: 'user', content: 'Hello' }], + reasoning_effort: 'high', + }); + }); + + it('should allow disabling thinking via top-level settings', async () => { + prepareJsonFixtureResponse('openai-text'); + + const model = provider.chat('o4-mini'); + + await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'disabled' }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: 'o4-mini', + messages: [{ role: 'user', content: 'Hello' }], + reasoning_effort: 'none', + }); + }); + + it('should prioritize top-level thinking over providerOptions reasoningEffort', async () => { + prepareJsonFixtureResponse('openai-text'); + + const model = provider.chat('o4-mini'); + + await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'enabled', effort: 'low' }, + providerOptions: { + openai: { reasoningEffort: 'high' }, + }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: 'o4-mini', + messages: [{ role: 'user', content: 'Hello' }], + reasoning_effort: 'low', + }); + }); + + it('should warn when thinking.budgetTokens is provided', async () => { + prepareJsonFixtureResponse('openai-text'); + + const model = provider.chat('o4-mini'); + + const { warnings } = await model.doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'enabled', effort: 'high', budgetTokens: 2048 }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: 'o4-mini', + messages: [{ role: 'user', content: 'Hello' }], + reasoning_effort: 'high', + }); + + expect(warnings).toContainEqual({ + type: 'unsupported', + feature: 'thinking.budgetTokens', + details: 'thinking.budgetTokens is not supported by the OpenAI chat API', + }); + }); + it('should pass tools and toolChoice', async () => { prepareJsonFixtureResponse('openai-text'); diff --git a/packages/openai/src/chat/openai-chat-language-model.ts b/packages/openai/src/chat/openai-chat-language-model.ts index f4eadae2e0e5..9b66b0e6bda9 100644 --- a/packages/openai/src/chat/openai-chat-language-model.ts +++ b/packages/openai/src/chat/openai-chat-language-model.ts @@ -48,6 +48,24 @@ type OpenAIChatConfig = { fetch?: FetchFunction; }; +function mapThinkingToOpenAIReasoningEffort({ + thinking, + isReasoningModel, +}: { + thinking: LanguageModelV3CallOptions['thinking'] | undefined; + isReasoningModel: boolean; +}) { + if (thinking == null) { + return undefined; + } + + if (thinking.type === 'disabled') { + return isReasoningModel ? 'none' : undefined; + } + + return thinking.effort; +} + export class OpenAIChatLanguageModel implements LanguageModelV3 { readonly specificationVersion = 'v3'; @@ -82,6 +100,7 @@ export class OpenAIChatLanguageModel implements LanguageModelV3 { tools, toolChoice, providerOptions, + thinking, }: LanguageModelV3CallOptions) { const warnings: SharedV3Warning[] = []; @@ -96,6 +115,22 @@ export class OpenAIChatLanguageModel implements LanguageModelV3 { const modelCapabilities = getOpenAILanguageModelCapabilities(this.modelId); const isReasoningModel = openaiOptions.forceReasoning ?? modelCapabilities.isReasoningModel; + const reasoningEffort = + thinking == null + ? openaiOptions.reasoningEffort + : mapThinkingToOpenAIReasoningEffort({ + thinking, + isReasoningModel, + }); + + if (thinking?.type === 'enabled' && thinking.budgetTokens != null) { + warnings.push({ + type: 'unsupported', + feature: 'thinking.budgetTokens', + details: + 'thinking.budgetTokens is not supported by the OpenAI chat API', + }); + } if (topK != null) { warnings.push({ type: 'unsupported', feature: 'topK' }); @@ -168,7 +203,7 @@ export class OpenAIChatLanguageModel implements LanguageModelV3 { store: openaiOptions.store, metadata: openaiOptions.metadata, prediction: openaiOptions.prediction, - reasoning_effort: openaiOptions.reasoningEffort, + reasoning_effort: reasoningEffort, service_tier: openaiOptions.serviceTier, prompt_cache_key: openaiOptions.promptCacheKey, prompt_cache_retention: openaiOptions.promptCacheRetention, @@ -184,7 +219,7 @@ export class OpenAIChatLanguageModel implements LanguageModelV3 { // when reasoning effort is none, gpt-5.1 models allow temperature, topP, logprobs // https://platform.openai.com/docs/guides/latest-model#gpt-5-1-parameter-compatibility if ( - openaiOptions.reasoningEffort !== 'none' || + reasoningEffort !== 'none' || !modelCapabilities.supportsNonReasoningParameters ) { if (baseArgs.temperature != null) { diff --git a/packages/openai/src/responses/openai-responses-language-model.test.ts b/packages/openai/src/responses/openai-responses-language-model.test.ts index be93fdca4479..e44775e885ef 100644 --- a/packages/openai/src/responses/openai-responses-language-model.test.ts +++ b/packages/openai/src/responses/openai-responses-language-model.test.ts @@ -748,6 +748,119 @@ describe('OpenAIResponsesLanguageModel', () => { }, ); + it.each(openaiResponsesReasoningModelIds)( + 'should send top-level thinking effort for %s', + async modelId => { + const { warnings } = await createModel(modelId).doGenerate({ + prompt: TEST_PROMPT, + thinking: { + type: 'enabled', + effort: 'high', + }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: modelId, + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + reasoning: { + effort: 'high', + }, + }); + + expect(warnings).toStrictEqual([]); + }, + ); + + it('should prioritize top-level thinking over providerOptions reasoningEffort', async () => { + const { warnings } = await createModel('o4-mini').doGenerate({ + prompt: TEST_PROMPT, + thinking: { + type: 'enabled', + effort: 'low', + }, + providerOptions: { + openai: { + reasoningEffort: 'high', + } satisfies OpenAILanguageModelResponsesOptions, + }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: 'o4-mini', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + reasoning: { + effort: 'low', + }, + }); + + expect(warnings).toStrictEqual([]); + }); + + it('should allow disabling thinking via top-level settings for reasoning models', async () => { + const { warnings } = await createModel('o4-mini').doGenerate({ + prompt: TEST_PROMPT, + thinking: { + type: 'disabled', + }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: 'o4-mini', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + reasoning: { + effort: 'none', + }, + }); + + expect(warnings).toStrictEqual([]); + }); + + it('should warn when thinking.budgetTokens is provided via top-level settings', async () => { + const { warnings } = await createModel('o4-mini').doGenerate({ + prompt: TEST_PROMPT, + thinking: { + type: 'enabled', + effort: 'low', + budgetTokens: 2048, + }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: 'o4-mini', + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + reasoning: { + effort: 'low', + }, + }); + + expect(warnings).toContainEqual({ + type: 'unsupported', + feature: 'thinking.budgetTokens', + details: + 'thinking.budgetTokens is not supported by the OpenAI responses API', + }); + }); + it('should allow forcing reasoning mode for unrecognized model IDs via providerOptions', async () => { const { warnings } = await createModel( 'stealth-reasoning-model', @@ -838,6 +951,28 @@ describe('OpenAIResponsesLanguageModel', () => { }, ); + it.each(nonReasoningModelIds)( + 'should ignore disabled top-level thinking without warnings for %s', + async modelId => { + const { warnings } = await createModel(modelId).doGenerate({ + prompt: TEST_PROMPT, + thinking: { type: 'disabled' }, + }); + + expect(await server.calls[0].requestBodyJson).toStrictEqual({ + model: modelId, + input: [ + { + role: 'user', + content: [{ type: 'input_text', text: 'Hello' }], + }, + ], + }); + + expect(warnings).toStrictEqual([]); + }, + ); + it('should send instructions provider option', async () => { const { warnings } = await createModel('gpt-4o').doGenerate({ prompt: TEST_PROMPT, diff --git a/packages/openai/src/responses/openai-responses-language-model.ts b/packages/openai/src/responses/openai-responses-language-model.ts index cf501550f57f..d832c1b5bbf9 100644 --- a/packages/openai/src/responses/openai-responses-language-model.ts +++ b/packages/openai/src/responses/openai-responses-language-model.ts @@ -69,6 +69,24 @@ import { ResponsesTextProviderMetadata, } from './openai-responses-provider-metadata'; +function mapThinkingToOpenAIReasoningEffort({ + thinking, + isReasoningModel, +}: { + thinking: LanguageModelV3CallOptions['thinking'] | undefined; + isReasoningModel: boolean; +}) { + if (thinking == null) { + return undefined; + } + + if (thinking.type === 'disabled') { + return isReasoningModel ? 'none' : undefined; + } + + return thinking.effort; +} + /** * Extracts a mapping from MCP approval request IDs to their corresponding tool call IDs * from the prompt. When an MCP tool requires approval, we generate a tool call ID to track @@ -129,6 +147,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { tools, toolChoice, responseFormat, + thinking, }: LanguageModelV3CallOptions) { const warnings: SharedV3Warning[] = []; const modelCapabilities = getOpenAILanguageModelCapabilities(this.modelId); @@ -172,6 +191,22 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { const isReasoningModel = openaiOptions?.forceReasoning ?? modelCapabilities.isReasoningModel; + const reasoningEffort = + thinking == null + ? openaiOptions?.reasoningEffort + : mapThinkingToOpenAIReasoningEffort({ + thinking, + isReasoningModel, + }); + + if (thinking?.type === 'enabled' && thinking.budgetTokens != null) { + warnings.push({ + type: 'unsupported', + feature: 'thinking.budgetTokens', + details: + 'thinking.budgetTokens is not supported by the OpenAI responses API', + }); + } if (openaiOptions?.conversation && openaiOptions?.previousResponseId) { warnings.push({ @@ -318,11 +353,11 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { // model-specific settings: ...(isReasoningModel && - (openaiOptions?.reasoningEffort != null || + (reasoningEffort != null || openaiOptions?.reasoningSummary != null) && { reasoning: { - ...(openaiOptions?.reasoningEffort != null && { - effort: openaiOptions.reasoningEffort, + ...(reasoningEffort != null && { + effort: reasoningEffort, }), ...(openaiOptions?.reasoningSummary != null && { summary: openaiOptions.reasoningSummary, @@ -338,7 +373,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { // https://platform.openai.com/docs/guides/latest-model#gpt-5-1-parameter-compatibility if ( !( - openaiOptions?.reasoningEffort === 'none' && + reasoningEffort === 'none' && modelCapabilities.supportsNonReasoningParameters ) ) { @@ -361,7 +396,7 @@ export class OpenAIResponsesLanguageModel implements LanguageModelV3 { } } } else { - if (openaiOptions?.reasoningEffort != null) { + if (reasoningEffort != null) { warnings.push({ type: 'unsupported', feature: 'reasoningEffort', diff --git a/packages/provider/src/language-model/v3/index.ts b/packages/provider/src/language-model/v3/index.ts index 4294793a42d4..103fb8c749f4 100644 --- a/packages/provider/src/language-model/v3/index.ts +++ b/packages/provider/src/language-model/v3/index.ts @@ -14,6 +14,7 @@ export * from './language-model-v3-source'; export * from './language-model-v3-stream-part'; export * from './language-model-v3-stream-result'; export * from './language-model-v3-text'; +export * from './language-model-v3-thinking'; export * from './language-model-v3-tool-approval-request'; export * from './language-model-v3-tool-call'; export * from './language-model-v3-tool-choice'; diff --git a/packages/provider/src/language-model/v3/language-model-v3-call-options.ts b/packages/provider/src/language-model/v3/language-model-v3-call-options.ts index 820438be62d7..58968e3a8c2b 100644 --- a/packages/provider/src/language-model/v3/language-model-v3-call-options.ts +++ b/packages/provider/src/language-model/v3/language-model-v3-call-options.ts @@ -3,6 +3,7 @@ import { SharedV3ProviderOptions } from '../../shared/v3/shared-v3-provider-opti import { LanguageModelV3FunctionTool } from './language-model-v3-function-tool'; import { LanguageModelV3Prompt } from './language-model-v3-prompt'; import { LanguageModelV3ProviderTool } from './language-model-v3-provider-tool'; +import { LanguageModelV3Thinking } from './language-model-v3-thinking'; import { LanguageModelV3ToolChoice } from './language-model-v3-tool-choice'; export type LanguageModelV3CallOptions = { @@ -90,6 +91,13 @@ export type LanguageModelV3CallOptions = { */ seed?: number; + /** + * Top-level thinking / reasoning configuration. + * + * Providers that do not support thinking may ignore this setting and emit warnings. + */ + thinking?: LanguageModelV3Thinking; + /** * The tools that are available for the model. */ diff --git a/packages/provider/src/language-model/v3/language-model-v3-thinking.ts b/packages/provider/src/language-model/v3/language-model-v3-thinking.ts new file mode 100644 index 000000000000..a4d0d5a68fad --- /dev/null +++ b/packages/provider/src/language-model/v3/language-model-v3-thinking.ts @@ -0,0 +1,26 @@ +/** + * Top-level reasoning / thinking configuration for language model calls. + */ +export type LanguageModelV3Thinking = + | { + /** + * Disable thinking / reasoning for providers that support it. + */ + type: 'disabled'; + } + | { + /** + * Enable thinking / reasoning for providers that support it. + */ + type: 'enabled'; + + /** + * Relative effort level for providers that support effort-based thinking controls. + */ + effort?: 'low' | 'medium' | 'high'; + + /** + * Token budget for providers that support budget-based thinking controls. + */ + budgetTokens?: number; + };