Skip to content

Commit 0531f31

Browse files
committed
fix: support thinking with structured output in AI SDK runner
AI SDK `generateObject()` seems to not work well with the Anthropic extended thinking mode (which seems to be rarely used by people..?). This commit configures the Anthropic provider differently when needed to support extended thinking w/ structured responses.
1 parent eff6655 commit 0531f31

File tree

4 files changed

+51
-16
lines changed

4 files changed

+51
-16
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
"@ai-sdk/anthropic": "^2.0.45",
5555
"@ai-sdk/google": "^2.0.39",
5656
"@ai-sdk/openai": "^2.0.71",
57+
"@ai-sdk/provider": "^2.0.0",
5758
"@anthropic-ai/sdk": "^0.68.0",
5859
"@axe-core/puppeteer": "^4.10.2",
5960
"@genkit-ai/compat-oai": "1.23.0",

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type {LanguageModelV2Middleware} from '@ai-sdk/provider';
2+
3+
/**
4+
* Middleware for Anthropic AI SDK models that is necessary for enabling
5+
* thinking mode + structured responses.
6+
*
7+
* This is necessary because Anthropic would be used with enforced tool usage
8+
* by default with `generateObject()`. This is a workaround that makes the tool
9+
* optional: https://github.com/vercel/ai/issues/9351.
10+
*/
11+
export const anthropicThinkingWithStructuredResponseMiddleware: LanguageModelV2Middleware = {
12+
transformParams: ({params}) => {
13+
if (params.responseFormat?.type === 'json' && params.responseFormat.schema) {
14+
params.tools = [
15+
{
16+
type: 'function',
17+
description: 'Respond with a JSON object for the structured output/answer.',
18+
inputSchema: params.responseFormat.schema,
19+
name: 'json',
20+
},
21+
];
22+
params.toolChoice = {type: 'auto'};
23+
24+
params.prompt.push({
25+
role: 'user',
26+
content: [
27+
{
28+
type: 'text',
29+
text: 'Use the `json` tool to provide the structured output/answer. No other text is needed.',
30+
},
31+
],
32+
});
33+
}
34+
return Promise.resolve(params);
35+
},
36+
};

runner/codegen/ai-sdk-runner.ts

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,15 @@ import {
1616
ModelMessage,
1717
SystemModelMessage,
1818
TextPart,
19+
wrapLanguageModel,
1920
} from 'ai';
2021
import {google, GoogleGenerativeAIProviderOptions} from '@ai-sdk/google';
2122
import {anthropic, AnthropicProviderOptions} from '@ai-sdk/anthropic';
2223
import {openai, OpenAIResponsesProviderOptions} from '@ai-sdk/openai';
2324
import z from 'zod';
2425
import {callWithTimeout} from '../utils/timeout.js';
2526
import {combineAbortSignals} from '../utils/abort-signal.js';
27+
import {anthropicThinkingWithStructuredResponseMiddleware} from './ai-sdk-claude-thinking-patch.js';
2628

2729
const SUPPORTED_MODELS = [
2830
'claude-opus-4.1-no-thinking',
@@ -159,26 +161,19 @@ export class AiSDKRunner implements LlmRunner {
159161
const modelName = request.model as (typeof SUPPORTED_MODELS)[number];
160162
switch (modelName) {
161163
case 'claude-opus-4.1-no-thinking':
162-
case 'claude-opus-4.1-with-thinking-16k': {
163-
const thinkingEnabled = modelName.includes('-with-thinking');
164-
return {
165-
model: anthropic('claude-opus-4-1'),
166-
providerOptions: {
167-
anthropic: {
168-
sendReasoning: thinkingEnabled,
169-
thinking: {
170-
type: thinkingEnabled ? 'enabled' : 'disabled',
171-
budgetTokens: thinkingEnabled ? claude16kThinkingTokenBudget : undefined,
172-
},
173-
} satisfies AnthropicProviderOptions,
174-
},
175-
};
176-
}
164+
case 'claude-opus-4.1-with-thinking-16k':
177165
case 'claude-sonnet-4.5-no-thinking':
178166
case 'claude-sonnet-4.5-with-thinking-16k': {
179167
const thinkingEnabled = modelName.includes('-with-thinking');
168+
const isOpus4_1Model = modelName.includes('opus-4.1');
169+
const model = anthropic(isOpus4_1Model ? 'claude-opus-4-1' : 'claude-sonnet-4-5');
180170
return {
181-
model: anthropic('claude-sonnet-4-5'),
171+
model: thinkingEnabled
172+
? wrapLanguageModel({
173+
model,
174+
middleware: anthropicThinkingWithStructuredResponseMiddleware,
175+
})
176+
: model,
182177
providerOptions: {
183178
anthropic: {
184179
sendReasoning: thinkingEnabled,

0 commit comments

Comments
 (0)