Skip to content

Commit 6e80c8d

Browse files
committed
feat: update @anthropic-ai/sdk to version 0.74.0 and use real structured output
1 parent c10cd97 commit 6e80c8d

File tree

10 files changed

+355
-106
lines changed

10 files changed

+355
-106
lines changed
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@tanstack/ai-anthropic': minor
3+
---
4+
5+
Use Anthropic's native structured output API instead of the tool-use workaround
6+
7+
Upgrades `@anthropic-ai/sdk` from ^0.71.2 to ^0.74.0 and migrates structured output to use the GA `output_config.format` with `json_schema` type. Previously, structured output was emulated by forcing a tool call and extracting the input — this now uses Anthropic's first-class structured output support for more reliable schema-constrained responses.
8+
9+
Also migrates streaming and tool types from `client.beta.messages` to the stable `client.messages` API, replacing beta type imports (`BetaToolChoiceAuto`, `BetaToolBash20241022`, `BetaRawMessageStreamEvent`, etc.) with their GA equivalents.
10+
11+
**No breaking changes to runtime behavior.** However, this is a **type-level breaking change** for TypeScript consumers who import tool choice or streaming types directly: the beta type exports (`BetaToolChoiceAuto`, `BetaToolChoiceTool`, `BetaRawMessageStreamEvent`, etc.) have been replaced with their GA equivalents (`ToolChoiceAuto`, `ToolChoiceTool`, `RawMessageStreamEvent`, etc.) from `@anthropic-ai/sdk/resources/messages`. Consumers referencing these types will need to update both the import paths and the type names accordingly.

packages/typescript/ai-anthropic/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"test:types": "tsc"
4141
},
4242
"dependencies": {
43-
"@anthropic-ai/sdk": "^0.71.2"
43+
"@anthropic-ai/sdk": "^0.74.0"
4444
},
4545
"peerDependencies": {
4646
"@tanstack/ai": "workspace:^",

packages/typescript/ai-anthropic/src/adapters/text.ts

Lines changed: 126 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
generateId,
77
getAnthropicApiKeyFromEnv,
88
} from '../utils'
9+
import { ANTHROPIC_STRUCTURED_OUTPUT_MODELS } from '../model-meta'
910
import type {
1011
ANTHROPIC_MODELS,
1112
AnthropicChatModelProviderOptionsByName,
@@ -21,6 +22,7 @@ import type {
2122
DocumentBlockParam,
2223
ImageBlockParam,
2324
MessageParam,
25+
RawMessageStreamEvent,
2426
TextBlockParam,
2527
URLImageSource,
2628
URLPDFSource,
@@ -115,13 +117,16 @@ export class AnthropicTextAdapter<
115117
this.client = createAnthropicClient(config)
116118
}
117119

120+
/**
121+
* Stream chat completions from Anthropic, yielding AG-UI lifecycle chunks.
122+
*/
118123
async *chatStream(
119124
options: TextOptions<AnthropicTextProviderOptions>,
120125
): AsyncIterable<StreamChunk> {
121126
try {
122127
const requestParams = this.mapCommonOptionsToAnthropic(options)
123128

124-
const stream = await this.client.beta.messages.create(
129+
const stream = await this.client.messages.create(
125130
{ ...requestParams, stream: true },
126131
{
127132
signal: options.request?.signal,
@@ -147,20 +152,87 @@ export class AnthropicTextAdapter<
147152
}
148153

149154
/**
150-
* Generate structured output using Anthropic's tool-based approach.
151-
* Anthropic doesn't have native structured output, so we use a tool with the schema
152-
* and force the model to call it.
153-
* The outputSchema is already JSON Schema (converted in the ai layer).
155+
* Generate structured output.
156+
* Uses Anthropic's native `output_config` with `json_schema` for Claude 4+ models.
157+
* Falls back to a tool-use workaround for older models that lack native support.
154158
*/
155159
async structuredOutput(
156160
options: StructuredOutputOptions<AnthropicTextProviderOptions>,
157161
): Promise<StructuredOutputResult<unknown>> {
158162
const { chatOptions, outputSchema } = options
159-
160163
const requestParams = this.mapCommonOptionsToAnthropic(chatOptions)
161164

162-
// Create a tool that will capture the structured output
163-
// Anthropic's SDK requires input_schema with type: 'object' literal
165+
if (ANTHROPIC_STRUCTURED_OUTPUT_MODELS.has(chatOptions.model)) {
166+
return this.nativeStructuredOutput(requestParams, chatOptions, outputSchema)
167+
}
168+
169+
return this.toolBasedStructuredOutput(requestParams, chatOptions, outputSchema)
170+
}
171+
172+
/**
173+
* Native structured output using `output_config.format` with `json_schema`.
174+
* Supported by Claude 4+ models.
175+
*/
176+
private async nativeStructuredOutput(
177+
requestParams: InternalTextProviderOptions,
178+
chatOptions: StructuredOutputOptions<AnthropicTextProviderOptions>['chatOptions'],
179+
outputSchema: StructuredOutputOptions<AnthropicTextProviderOptions>['outputSchema'],
180+
): Promise<StructuredOutputResult<unknown>> {
181+
const createParams = {
182+
...requestParams,
183+
stream: false as const,
184+
output_config: {
185+
format: {
186+
type: 'json_schema' as const,
187+
name: 'structured_output',
188+
schema: outputSchema,
189+
},
190+
},
191+
}
192+
193+
let response: Awaited<ReturnType<typeof this.client.messages.create>>
194+
try {
195+
response = await this.client.messages.create(createParams, {
196+
signal: chatOptions.request?.signal,
197+
headers: chatOptions.request?.headers,
198+
})
199+
} catch (error: unknown) {
200+
const err = error as Error
201+
throw new Error(
202+
`Structured output generation failed: ${err.message || 'Unknown error occurred'}`,
203+
)
204+
}
205+
206+
const rawText = response.content
207+
.map((b) => {
208+
if (b.type === 'text') {
209+
return b.text
210+
}
211+
return ''
212+
})
213+
.join('')
214+
215+
let parsed: unknown
216+
try {
217+
parsed = JSON.parse(rawText)
218+
} catch {
219+
throw new Error(
220+
`Failed to parse structured output JSON. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
221+
)
222+
}
223+
224+
return { data: parsed, rawText }
225+
}
226+
227+
/**
228+
* Tool-based structured output fallback for older models (Claude 3.x).
229+
* Creates a tool with the output schema and forces the model to call it.
230+
*/
231+
private async toolBasedStructuredOutput(
232+
requestParams: InternalTextProviderOptions,
233+
chatOptions: StructuredOutputOptions<AnthropicTextProviderOptions>['chatOptions'],
234+
outputSchema: StructuredOutputOptions<AnthropicTextProviderOptions>['outputSchema'],
235+
): Promise<StructuredOutputResult<unknown>> {
164236
const structuredOutputTool = {
165237
name: 'structured_output',
166238
description:
@@ -172,9 +244,9 @@ export class AnthropicTextAdapter<
172244
},
173245
}
174246

247+
let response: Awaited<ReturnType<typeof this.client.messages.create>>
175248
try {
176-
// Make non-streaming request with tool_choice forced to our structured output tool
177-
const response = await this.client.messages.create(
249+
response = await this.client.messages.create(
178250
{
179251
...requestParams,
180252
stream: false,
@@ -186,50 +258,48 @@ export class AnthropicTextAdapter<
186258
headers: chatOptions.request?.headers,
187259
},
188260
)
189-
190-
// Extract the tool use content from the response
191-
let parsed: unknown = null
192-
let rawText = ''
193-
194-
for (const block of response.content) {
195-
if (block.type === 'tool_use' && block.name === 'structured_output') {
196-
parsed = block.input
197-
rawText = JSON.stringify(block.input)
198-
break
199-
}
200-
}
201-
202-
if (parsed === null) {
203-
// Fallback: try to extract text content and parse as JSON
204-
rawText = response.content
205-
.map((b) => {
206-
if (b.type === 'text') {
207-
return b.text
208-
}
209-
return ''
210-
})
211-
.join('')
212-
try {
213-
parsed = JSON.parse(rawText)
214-
} catch {
215-
throw new Error(
216-
`Failed to extract structured output from response. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
217-
)
218-
}
219-
}
220-
221-
return {
222-
data: parsed,
223-
rawText,
224-
}
225261
} catch (error: unknown) {
226262
const err = error as Error
227263
throw new Error(
228264
`Structured output generation failed: ${err.message || 'Unknown error occurred'}`,
229265
)
230266
}
267+
268+
let parsed: unknown = null
269+
let rawText = ''
270+
271+
for (const block of response.content) {
272+
if (block.type === 'tool_use' && block.name === 'structured_output') {
273+
parsed = block.input
274+
rawText = JSON.stringify(block.input)
275+
break
276+
}
277+
}
278+
279+
if (parsed === null) {
280+
rawText = response.content
281+
.map((b) => {
282+
if (b.type === 'text') {
283+
return b.text
284+
}
285+
return ''
286+
})
287+
.join('')
288+
try {
289+
parsed = JSON.parse(rawText)
290+
} catch {
291+
throw new Error(
292+
`Failed to extract structured output from response. Content: ${rawText.slice(0, 200)}${rawText.length > 200 ? '...' : ''}`,
293+
)
294+
}
295+
}
296+
297+
return { data: parsed, rawText }
231298
}
232299

300+
/**
301+
* Map framework-agnostic text options to the Anthropic request format.
302+
*/
233303
private mapCommonOptionsToAnthropic(
234304
options: TextOptions<AnthropicTextProviderOptions>,
235305
) {
@@ -293,6 +363,9 @@ export class AnthropicTextAdapter<
293363
return requestParams
294364
}
295365

366+
/**
367+
* Convert a framework-agnostic content part to an Anthropic content block.
368+
*/
296369
private convertContentPartToAnthropic(
297370
part: ContentPart,
298371
): TextBlockParam | ImageBlockParam | DocumentBlockParam {
@@ -362,6 +435,9 @@ export class AnthropicTextAdapter<
362435
}
363436
}
364437

438+
/**
439+
* Convert framework-agnostic messages to Anthropic's message format.
440+
*/
365441
private formatMessages(
366442
messages: Array<ModelMessage>,
367443
): InternalTextProviderOptions['messages'] {
@@ -453,8 +529,11 @@ export class AnthropicTextAdapter<
453529
return formattedMessages
454530
}
455531

532+
/**
533+
* Process a raw Anthropic SSE stream into AG-UI lifecycle chunks.
534+
*/
456535
private async *processAnthropicStream(
457-
stream: AsyncIterable<Anthropic_SDK.Beta.BetaRawMessageStreamEvent>,
536+
stream: AsyncIterable<RawMessageStreamEvent>,
458537
model: string,
459538
genId: () => string,
460539
): AsyncIterable<StreamChunk> {

packages/typescript/ai-anthropic/src/model-meta.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ interface ModelMeta<
2020
input: Array<'text' | 'image' | 'audio' | 'video' | 'document'>
2121
extended_thinking?: boolean
2222
priority_tier?: boolean
23+
structured_output?: boolean
2324
}
2425
context_window?: number
2526
max_output_tokens?: number
@@ -65,6 +66,7 @@ const CLAUDE_OPUS_4_6 = {
6566
input: ['text', 'image', 'document'],
6667
extended_thinking: true,
6768
priority_tier: true,
69+
structured_output: true,
6870
},
6971
} as const satisfies ModelMeta<
7072
AnthropicContainerOptions &
@@ -95,6 +97,7 @@ const CLAUDE_OPUS_4_5 = {
9597
input: ['text', 'image', 'document'],
9698
extended_thinking: true,
9799
priority_tier: true,
100+
structured_output: true,
98101
},
99102
} as const satisfies ModelMeta<
100103
AnthropicContainerOptions &
@@ -125,6 +128,7 @@ const CLAUDE_SONNET_4_5 = {
125128
input: ['text', 'image', 'document'],
126129
extended_thinking: true,
127130
priority_tier: true,
131+
structured_output: true,
128132
},
129133
} as const satisfies ModelMeta<
130134
AnthropicContainerOptions &
@@ -155,6 +159,7 @@ const CLAUDE_HAIKU_4_5 = {
155159
input: ['text', 'image', 'document'],
156160
extended_thinking: true,
157161
priority_tier: true,
162+
structured_output: true,
158163
},
159164
} as const satisfies ModelMeta<
160165
AnthropicContainerOptions &
@@ -185,6 +190,7 @@ const CLAUDE_OPUS_4_1 = {
185190
input: ['text', 'image', 'document'],
186191
extended_thinking: true,
187192
priority_tier: true,
193+
structured_output: true,
188194
},
189195
} as const satisfies ModelMeta<
190196
AnthropicContainerOptions &
@@ -215,6 +221,7 @@ const CLAUDE_SONNET_4 = {
215221
input: ['text', 'image', 'document'],
216222
extended_thinking: true,
217223
priority_tier: true,
224+
structured_output: true,
218225
},
219226
} as const satisfies ModelMeta<
220227
AnthropicContainerOptions &
@@ -244,6 +251,7 @@ const CLAUDE_SONNET_3_7 = {
244251
input: ['text', 'image', 'document'],
245252
extended_thinking: true,
246253
priority_tier: true,
254+
structured_output: false,
247255
},
248256
} as const satisfies ModelMeta<
249257
AnthropicContainerOptions &
@@ -274,6 +282,7 @@ const CLAUDE_OPUS_4 = {
274282
input: ['text', 'image', 'document'],
275283
extended_thinking: true,
276284
priority_tier: true,
285+
structured_output: true,
277286
},
278287
} as const satisfies ModelMeta<
279288
AnthropicContainerOptions &
@@ -304,6 +313,7 @@ const CLAUDE_HAIKU_3_5 = {
304313
input: ['text', 'image', 'document'],
305314
extended_thinking: false,
306315
priority_tier: true,
316+
structured_output: false,
307317
},
308318
} as const satisfies ModelMeta<
309319
AnthropicContainerOptions &
@@ -334,6 +344,7 @@ const CLAUDE_HAIKU_3 = {
334344
input: ['text', 'image', 'document'],
335345
extended_thinking: false,
336346
priority_tier: false,
347+
structured_output: false,
337348
},
338349
} as const satisfies ModelMeta<
339350
AnthropicContainerOptions &
@@ -404,6 +415,20 @@ export const ANTHROPIC_MODELS = [
404415
CLAUDE_HAIKU_3.id,
405416
] as const
406417

418+
/**
419+
* Models that support Anthropic's native structured output API (output_config with json_schema).
420+
* Only Claude 4+ models support this feature.
421+
*/
422+
export const ANTHROPIC_STRUCTURED_OUTPUT_MODELS: ReadonlySet<string> = new Set([
423+
CLAUDE_OPUS_4_6.id,
424+
CLAUDE_OPUS_4_5.id,
425+
CLAUDE_SONNET_4_5.id,
426+
CLAUDE_HAIKU_4_5.id,
427+
CLAUDE_OPUS_4_1.id,
428+
CLAUDE_SONNET_4.id,
429+
CLAUDE_OPUS_4.id,
430+
])
431+
407432
// const ANTHROPIC_IMAGE_MODELS = [] as const
408433
// const ANTHROPIC_EMBEDDING_MODELS = [] as const
409434
// const ANTHROPIC_AUDIO_MODELS = [] as const

0 commit comments

Comments
 (0)