@ai-sdk/anthropic 3.0.136 → 3.0.137

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -194,10 +194,21 @@ default `"auto"` mode uses native structured outputs through
194
194
  `output_config.format`. These models reject forced tool use, so the
195
195
  `"jsonTool"` mode cannot be used with them. If you
196
196
  request `"jsonTool"` anyway, the provider falls back to `"outputFormat"` and
197
- emits a warning. Likewise, a `required` or named `toolChoice` is downgraded to
197
+ emits a warning. The same fallback applies when these models are served through
198
+ providers that otherwise default to the JSON tool, such as the Amazon Bedrock
199
+ Anthropic and Google Vertex Anthropic providers. On those providers, schemas
200
+ that native structured outputs cannot represent keep the JSON tool:
201
+ self-referencing schemas, schemas with more than 10 optional properties, and
202
+ schemas with open objects such as maps (`additionalProperties` set to a schema
203
+ or `true`), which native structured outputs would return as `{}`.
204
+ Likewise, a `required` or named `toolChoice` is downgraded to
198
205
  `auto` with a warning; instruct the model to use the tool in your prompt and
199
206
  verify that a tool call was made.
200
207
 
208
+ When the JSON tool cannot be forced (for example with manual thinking) and the
209
+ model answers in text without calling it, the text of its final answer is
210
+ returned. Text before a tool call is still dropped.
211
+
201
212
  ### Effort
202
213
 
203
214
  Anthropic introduced an `effort` option with `claude-opus-4-5` that affects thinking, text responses, and function calls. Effort defaults to `high` and you can set it to `medium` or `low` to save tokens and to lower time-to-last-token latency (TTLT). `claude-opus-4-7`, `claude-opus-4-8`, `claude-opus-5`, `claude-opus-5-5`, `claude-fable-5`, `claude-fable-5-1`, `claude-sonnet-5`, `claude-sonnet-5-5`, and `claude-haiku-5-5` additionally support `xhigh` for maximum reasoning effort.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ai-sdk/anthropic",
3
- "version": "3.0.136",
3
+ "version": "3.0.137",
4
4
  "license": "Apache-2.0",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -63,6 +63,7 @@ import {
63
63
  import { CacheControlValidator } from './get-cache-control';
64
64
  import { mapAnthropicStopReason } from './map-anthropic-stop-reason';
65
65
  import { sanitizeJsonSchema } from './sanitize-json-schema';
66
+ import { supportsNativeStructuredOutputSchema } from './supports-native-structured-output-schema';
66
67
 
67
68
  function createCitationSource(
68
69
  citation: Citation,
@@ -216,6 +217,47 @@ type AnthropicMessagesConfig = {
216
217
  supportsStrictTools?: boolean;
217
218
  };
218
219
 
220
+ const TEXT_OR_THINKING_BLOCK_TYPES = new Set<string>([
221
+ 'redacted_thinking',
222
+ 'text',
223
+ 'thinking',
224
+ ]);
225
+
226
+ function isContinuationStopReason(
227
+ stopReason: string | null | undefined,
228
+ ): boolean {
229
+ return stopReason === 'pause_turn' || stopReason === 'tool_use';
230
+ }
231
+
232
+ /**
233
+ * Text parts to return when the JSON response tool is in use: the text after
234
+ * the last tool call or result, and only if the tool was never called.
235
+ */
236
+ function getJsonResponseToolFallbackText<
237
+ PART extends { type: string; name?: unknown },
238
+ >(
239
+ content: ReadonlyArray<PART>,
240
+ stopReason: string | null | undefined,
241
+ ): Set<PART> {
242
+ const text = new Set<PART>();
243
+ if (isContinuationStopReason(stopReason)) {
244
+ return text;
245
+ }
246
+
247
+ for (const part of content) {
248
+ if (part.type === 'tool_use' && part.name === 'json') {
249
+ return new Set();
250
+ }
251
+ if (part.type === 'text') {
252
+ text.add(part);
253
+ } else if (!TEXT_OR_THINKING_BLOCK_TYPES.has(part.type)) {
254
+ text.clear();
255
+ }
256
+ }
257
+
258
+ return text;
259
+ }
260
+
219
261
  export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
220
262
  readonly specificationVersion = 'v3';
221
263
 
@@ -419,13 +461,15 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
419
461
 
420
462
  // The JSON response tool relies on forced tool use, which some models
421
463
  // reject (e.g. Opus 5.5, Fable 5.1). Fall back to native structured
422
- // outputs when the model supports them.
464
+ // outputs even when the provider disables them by default (Bedrock,
465
+ // Vertex), unless they would reject the schema or lose data from it.
423
466
  if (
424
467
  !useStructuredOutput &&
425
468
  rejectsForcedToolUse &&
426
- supportsStructuredOutput &&
469
+ modelSupportsStructuredOutput &&
427
470
  responseFormat?.type === 'json' &&
428
- responseFormat.schema != null
471
+ responseFormat.schema != null &&
472
+ supportsNativeStructuredOutputSchema(responseFormat.schema)
429
473
  ) {
430
474
  warnings.push({
431
475
  type: 'unsupported',
@@ -1146,11 +1190,20 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
1146
1190
  const serverToolCalls: Record<string, string> = {}; // tool_use_id -> provider tool name
1147
1191
  let isJsonResponseFromTool = false;
1148
1192
 
1193
+ // The JSON response tool call becomes the text, so other text is dropped,
1194
+ // unless the model never called the tool and answered in text instead.
1195
+ const jsonResponseToolFallbackText = usesJsonResponseTool
1196
+ ? getJsonResponseToolFallbackText(response.content, response.stop_reason)
1197
+ : undefined;
1198
+
1149
1199
  // map response content to content array
1150
1200
  for (const part of response.content) {
1151
1201
  switch (part.type) {
1152
1202
  case 'text': {
1153
- if (!usesJsonResponseTool) {
1203
+ if (
1204
+ jsonResponseToolFallbackText == null ||
1205
+ jsonResponseToolFallbackText.has(part)
1206
+ ) {
1154
1207
  const webSearchCitations = part.citations?.filter(
1155
1208
  citation => citation.type === 'web_search_result_location',
1156
1209
  );
@@ -1793,6 +1846,21 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
1793
1846
  let safeguardResults: AnthropicMessageMetadata['safeguardResults'];
1794
1847
  let container: AnthropicMessageMetadata['container'] | null = null;
1795
1848
  let isJsonResponseFromTool = false;
1849
+ // Text held back while the JSON response tool is in use: it is only the
1850
+ // answer if no tool block follows it and the model never calls the tool.
1851
+ type HeldBackText = { id: string; text: string; citations: Citation[] };
1852
+ const heldBackText: HeldBackText[] = [];
1853
+ const heldBackTextByIndex = new Map<number, HeldBackText>();
1854
+ const createTextEnd = (
1855
+ id: string,
1856
+ citations: Citation[],
1857
+ ): LanguageModelV3StreamPart => ({
1858
+ type: 'text-end',
1859
+ id,
1860
+ ...(citations.length > 0 && {
1861
+ providerMetadata: { anthropic: { citations } },
1862
+ }),
1863
+ });
1796
1864
  let isMessageOpen = false;
1797
1865
  let activeMessageId: string | null | undefined;
1798
1866
  let hasInvalidMessageSequence = false;
@@ -1860,11 +1928,23 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
1860
1928
 
1861
1929
  blockType = contentBlockType;
1862
1930
 
1931
+ // text before a tool call or result is preamble, not the answer:
1932
+ if (!TEXT_OR_THINKING_BLOCK_TYPES.has(contentBlockType)) {
1933
+ heldBackText.length = 0;
1934
+ }
1935
+
1863
1936
  switch (contentBlockType) {
1864
1937
  case 'text': {
1865
1938
  // when a json response tool is used, the tool call is returned as text,
1866
- // so we ignore the text content:
1939
+ // so the text content is held back until we know whether it was called:
1867
1940
  if (usesJsonResponseTool) {
1941
+ const entry = {
1942
+ id: String(value.index),
1943
+ text: '',
1944
+ citations: [],
1945
+ };
1946
+ heldBackText.push(entry);
1947
+ heldBackTextByIndex.set(value.index, entry);
1868
1948
  return;
1869
1949
  }
1870
1950
 
@@ -2414,17 +2494,12 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
2414
2494
 
2415
2495
  switch (contentBlock.type) {
2416
2496
  case 'text': {
2417
- controller.enqueue({
2418
- type: 'text-end',
2419
- id: String(value.index),
2420
- ...(contentBlock.citations.length > 0 && {
2421
- providerMetadata: {
2422
- anthropic: {
2423
- citations: contentBlock.citations,
2424
- },
2425
- },
2426
- }),
2427
- });
2497
+ controller.enqueue(
2498
+ createTextEnd(
2499
+ String(value.index),
2500
+ contentBlock.citations,
2501
+ ),
2502
+ );
2428
2503
  break;
2429
2504
  }
2430
2505
 
@@ -2547,8 +2622,12 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
2547
2622
  switch (deltaType) {
2548
2623
  case 'text_delta': {
2549
2624
  // when a json response tool is used, the tool call is returned as text,
2550
- // so we ignore the text content:
2625
+ // so the text content is held back until we know whether it was called:
2551
2626
  if (usesJsonResponseTool) {
2627
+ const entry = heldBackTextByIndex.get(value.index);
2628
+ if (entry != null) {
2629
+ entry.text += value.delta.text;
2630
+ }
2552
2631
  return; // excluding the text-start will also exclude the text-end
2553
2632
  }
2554
2633
 
@@ -2659,11 +2738,13 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
2659
2738
  const citation = value.delta.citation;
2660
2739
  const contentBlock = contentBlocks[value.index];
2661
2740
 
2662
- if (
2663
- contentBlock?.type === 'text' &&
2664
- citation.type === 'web_search_result_location'
2665
- ) {
2666
- contentBlock.citations.push(citation);
2741
+ if (citation.type === 'web_search_result_location') {
2742
+ if (contentBlock?.type === 'text') {
2743
+ contentBlock.citations.push(citation);
2744
+ }
2745
+ heldBackTextByIndex
2746
+ .get(value.index)
2747
+ ?.citations.push(citation);
2667
2748
  }
2668
2749
 
2669
2750
  const source = createCitationSource(
@@ -2709,6 +2790,7 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
2709
2790
 
2710
2791
  isMessageOpen = true;
2711
2792
  activeMessageId = value.message.id;
2793
+ heldBackTextByIndex.clear();
2712
2794
 
2713
2795
  usage.input_tokens = value.message.usage.input_tokens;
2714
2796
  usage.cache_read_input_tokens =
@@ -2926,6 +3008,21 @@ export class AnthropicMessagesLanguageModel implements LanguageModelV3 {
2926
3008
  providerMetadata[providerOptionsName] = anthropicMetadata;
2927
3009
  }
2928
3010
 
3011
+ // a message that hands off to a tool or continuation is not the
3012
+ // final answer, so its held-back text is discarded:
3013
+ if (
3014
+ !isJsonResponseFromTool &&
3015
+ !isContinuationStopReason(finishReason.raw)
3016
+ ) {
3017
+ for (const { id, text, citations } of heldBackText) {
3018
+ if (text.length === 0) continue;
3019
+ controller.enqueue({ type: 'text-start', id });
3020
+ controller.enqueue({ type: 'text-delta', id, delta: text });
3021
+ controller.enqueue(createTextEnd(id, citations));
3022
+ }
3023
+ }
3024
+ heldBackText.length = 0;
3025
+
2929
3026
  controller.enqueue({
2930
3027
  type: 'finish',
2931
3028
  finishReason,
@@ -0,0 +1,118 @@
1
+ import type { JSONSchema7 } from '@ai-sdk/provider';
2
+
3
+ // Keywords whose values are maps of name -> schema rather than a schema.
4
+ const SCHEMA_MAP_KEYWORDS = new Set([
5
+ 'dependentSchemas',
6
+ 'patternProperties',
7
+ 'properties',
8
+ ]);
9
+
10
+ // Keywords holding instance data, which can contain a literal `$ref` key.
11
+ const DATA_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']);
12
+
13
+ /**
14
+ * Whether a JSON schema contains a cycle of local `$ref`s, e.g. a tree node
15
+ * whose children reference the node itself.
16
+ *
17
+ * Anthropic's native structured outputs reject such schemas, while the JSON
18
+ * response tool accepts them.
19
+ */
20
+ export function hasCircularSchemaReference(schema: JSONSchema7): boolean {
21
+ const visiting = new Set<string>();
22
+ const visited = new Set<string>();
23
+
24
+ const visit = (pointer: string): boolean => {
25
+ if (visiting.has(pointer)) {
26
+ return true;
27
+ }
28
+ if (visited.has(pointer)) {
29
+ return false;
30
+ }
31
+
32
+ const target = resolveSchemaPointer(schema, pointer);
33
+ if (target === undefined) {
34
+ return false;
35
+ }
36
+
37
+ visiting.add(pointer);
38
+ const isCircular = collectLocalRefs(target).some(visit);
39
+ visiting.delete(pointer);
40
+ visited.add(pointer);
41
+
42
+ return isCircular;
43
+ };
44
+
45
+ return visit('#');
46
+ }
47
+
48
+ function collectLocalRefs(node: unknown, refs: string[] = []): string[] {
49
+ if (Array.isArray(node)) {
50
+ for (const item of node) {
51
+ collectLocalRefs(item, refs);
52
+ }
53
+ return refs;
54
+ }
55
+
56
+ if (!isRecord(node)) {
57
+ return refs;
58
+ }
59
+
60
+ for (const [key, value] of Object.entries(node)) {
61
+ if (key === '$ref') {
62
+ if (typeof value === 'string' && value.startsWith('#')) {
63
+ refs.push(value);
64
+ }
65
+ } else if (SCHEMA_MAP_KEYWORDS.has(key) && isRecord(value)) {
66
+ for (const subschema of Object.values(value)) {
67
+ collectLocalRefs(subschema, refs);
68
+ }
69
+ } else if (
70
+ // definitions are only reached through a `$ref`:
71
+ key !== '$defs' &&
72
+ key !== 'definitions' &&
73
+ !DATA_KEYWORDS.has(key)
74
+ ) {
75
+ collectLocalRefs(value, refs);
76
+ }
77
+ }
78
+
79
+ return refs;
80
+ }
81
+
82
+ export function resolveSchemaPointer(
83
+ schema: JSONSchema7,
84
+ pointer: string,
85
+ ): unknown {
86
+ if (pointer === '#') {
87
+ return schema;
88
+ }
89
+ if (!pointer.startsWith('#/')) {
90
+ return undefined;
91
+ }
92
+
93
+ let decodedPointer: string;
94
+ try {
95
+ decodedPointer = decodeURIComponent(pointer.slice(2));
96
+ } catch {
97
+ return undefined;
98
+ }
99
+
100
+ let current: unknown = schema;
101
+ for (const rawSegment of decodedPointer.split('/')) {
102
+ const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~');
103
+
104
+ if (Array.isArray(current)) {
105
+ current = current[Number(segment)];
106
+ } else if (isRecord(current)) {
107
+ current = current[segment];
108
+ } else {
109
+ return undefined;
110
+ }
111
+ }
112
+
113
+ return current;
114
+ }
115
+
116
+ export function isRecord(value: unknown): value is Record<string, unknown> {
117
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
118
+ }
@@ -53,12 +53,11 @@ function sanitizeDefinition(
53
53
 
54
54
  function sanitizeSchema(schema: JSONSchema7): JSONSchema7 {
55
55
  const result: JSONSchema7 = {};
56
- const schemaWithDefs = schema as JSONSchema7 & {
57
- $defs?: Record<string, JSONSchema7Definition>;
58
- };
59
56
 
57
+ // Keep the definitions next to a `$ref` so a top-level reference still
58
+ // resolves (e.g. `{ $ref: '#/definitions/Answer', definitions: {...} }`).
60
59
  if (schema.$ref != null) {
61
- return { $ref: schema.$ref };
60
+ return { $ref: schema.$ref, ...sanitizeSchemaDefinitions(schema) };
62
61
  }
63
62
 
64
63
  if (schema.$schema != null) {
@@ -103,26 +102,7 @@ function sanitizeSchema(schema: JSONSchema7): JSONSchema7 {
103
102
  result.allOf = schema.allOf.map(sanitizeDefinition);
104
103
  }
105
104
 
106
- if (schema.definitions != null) {
107
- result.definitions = Object.fromEntries(
108
- Object.entries(schema.definitions).map(([name, definition]) => [
109
- name,
110
- sanitizeDefinition(definition),
111
- ]),
112
- );
113
- }
114
-
115
- if (schemaWithDefs.$defs != null) {
116
- const resultWithDefs = result as JSONSchema7 & {
117
- $defs?: Record<string, JSONSchema7Definition>;
118
- };
119
- resultWithDefs.$defs = Object.fromEntries(
120
- Object.entries(schemaWithDefs.$defs).map(([name, definition]) => [
121
- name,
122
- sanitizeDefinition(definition),
123
- ]),
124
- );
125
- }
105
+ Object.assign(result, sanitizeSchemaDefinitions(schema));
126
106
 
127
107
  if (schema.type === 'object' || schema.properties != null) {
128
108
  if (schema.properties != null) {
@@ -165,6 +145,29 @@ function sanitizeSchema(schema: JSONSchema7): JSONSchema7 {
165
145
  return result;
166
146
  }
167
147
 
148
+ function sanitizeSchemaDefinitions(schema: JSONSchema7): Pick<
149
+ JSONSchema7,
150
+ 'definitions'
151
+ > & {
152
+ $defs?: Record<string, JSONSchema7Definition>;
153
+ } {
154
+ const { $defs } = schema as { $defs?: Record<string, JSONSchema7Definition> };
155
+ const sanitizeAll = (definitions: Record<string, JSONSchema7Definition>) =>
156
+ Object.fromEntries(
157
+ Object.entries(definitions).map(([name, definition]) => [
158
+ name,
159
+ sanitizeDefinition(definition),
160
+ ]),
161
+ );
162
+
163
+ return {
164
+ ...(schema.definitions != null && {
165
+ definitions: sanitizeAll(schema.definitions),
166
+ }),
167
+ ...($defs != null && { $defs: sanitizeAll($defs) }),
168
+ };
169
+ }
170
+
168
171
  function getConstraintDescription(schema: JSONSchema7): string | undefined {
169
172
  const descriptions = DESCRIPTION_CONSTRAINT_KEYS.flatMap(key => {
170
173
  const value = schema[key];
@@ -0,0 +1,138 @@
1
+ import type { JSONSchema7 } from '@ai-sdk/provider';
2
+ import {
3
+ hasCircularSchemaReference,
4
+ isRecord,
5
+ resolveSchemaPointer,
6
+ } from './has-circular-schema-reference';
7
+
8
+ // Anthropic rejects schemas with more than 24 optional properties in total,
9
+ // and grammar compilation fails earlier as optional properties add up: from 13
10
+ // on Bedrock and the Anthropic API, from 11 on Vertex (compilation timeout).
11
+ const MAX_OPTIONAL_PROPERTIES = 10;
12
+
13
+ const SUBSCHEMA_KEYWORDS = [
14
+ 'additionalProperties',
15
+ 'contains',
16
+ 'else',
17
+ 'if',
18
+ 'items',
19
+ 'not',
20
+ 'then',
21
+ ];
22
+ const SUBSCHEMA_LIST_KEYWORDS = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
23
+ const SUBSCHEMA_MAP_KEYWORDS = [
24
+ 'dependentSchemas',
25
+ 'patternProperties',
26
+ 'properties',
27
+ ];
28
+
29
+ type SchemaStats = { optionalProperties: number; hasOpenObject: boolean };
30
+
31
+ /**
32
+ * Whether Anthropic's native structured outputs (`output_config.format`) can
33
+ * represent a schema without rejecting it or losing data.
34
+ *
35
+ * Rejected: self-referencing schemas, and schemas with too many optional
36
+ * properties. Lossy: open objects (maps, free-form objects), which native
37
+ * structured outputs close with `additionalProperties: false`, so the model
38
+ * can only return `{}` for them.
39
+ */
40
+ export function supportsNativeStructuredOutputSchema(
41
+ schema: JSONSchema7,
42
+ ): boolean {
43
+ if (hasCircularSchemaReference(schema)) {
44
+ return false;
45
+ }
46
+
47
+ const { optionalProperties, hasOpenObject } = getSchemaStats(
48
+ schema,
49
+ schema,
50
+ new Map(),
51
+ );
52
+
53
+ return !hasOpenObject && optionalProperties <= MAX_OPTIONAL_PROPERTIES;
54
+ }
55
+
56
+ function getSchemaStats(
57
+ node: unknown,
58
+ root: JSONSchema7,
59
+ refStats: Map<string, SchemaStats>,
60
+ ): SchemaStats {
61
+ const stats: SchemaStats = { optionalProperties: 0, hasOpenObject: false };
62
+ if (!isRecord(node)) {
63
+ return stats;
64
+ }
65
+
66
+ const add = (child: unknown) => {
67
+ const childStats = getSchemaStats(child, root, refStats);
68
+ stats.optionalProperties += childStats.optionalProperties;
69
+ stats.hasOpenObject ||= childStats.hasOpenObject;
70
+ };
71
+
72
+ // references are counted at every use; the schema has no cycles here:
73
+ if (typeof node.$ref === 'string' && node.$ref.startsWith('#')) {
74
+ let resolved = refStats.get(node.$ref);
75
+ if (resolved == null) {
76
+ resolved = getSchemaStats(
77
+ resolveSchemaPointer(root, node.$ref),
78
+ root,
79
+ refStats,
80
+ );
81
+ refStats.set(node.$ref, resolved);
82
+ }
83
+ stats.optionalProperties += resolved.optionalProperties;
84
+ stats.hasOpenObject ||= resolved.hasOpenObject;
85
+ }
86
+
87
+ if (isOpenObject(node)) {
88
+ stats.hasOpenObject = true;
89
+ }
90
+
91
+ if (isRecord(node.properties)) {
92
+ const required = new Set(Array.isArray(node.required) ? node.required : []);
93
+ stats.optionalProperties += Object.keys(node.properties).filter(
94
+ name => !required.has(name),
95
+ ).length;
96
+ }
97
+
98
+ for (const keyword of SUBSCHEMA_KEYWORDS) {
99
+ const value = node[keyword];
100
+ if (Array.isArray(value)) {
101
+ value.forEach(add);
102
+ } else {
103
+ add(value);
104
+ }
105
+ }
106
+ for (const keyword of SUBSCHEMA_LIST_KEYWORDS) {
107
+ const value = node[keyword];
108
+ if (Array.isArray(value)) {
109
+ value.forEach(add);
110
+ }
111
+ }
112
+ for (const keyword of SUBSCHEMA_MAP_KEYWORDS) {
113
+ const value = node[keyword];
114
+ if (isRecord(value)) {
115
+ Object.values(value).forEach(add);
116
+ }
117
+ }
118
+
119
+ return stats;
120
+ }
121
+
122
+ function isOpenObject(node: Record<string, unknown>): boolean {
123
+ const { additionalProperties, patternProperties, properties, type } = node;
124
+
125
+ if (additionalProperties === true || isRecord(additionalProperties)) {
126
+ return true;
127
+ }
128
+ if (
129
+ isRecord(patternProperties) &&
130
+ Object.keys(patternProperties).length > 0
131
+ ) {
132
+ return true;
133
+ }
134
+
135
+ const isObjectType =
136
+ type === 'object' || (Array.isArray(type) && type.includes('object'));
137
+ return isObjectType && properties == null && additionalProperties !== false;
138
+ }