@ai-sdk/anthropic 4.0.93 → 4.0.94

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.
@@ -355,10 +355,21 @@ default `"auto"` mode uses native structured outputs through
355
355
  `output_config.format`. These models reject forced tool use, so the
356
356
  `"jsonTool"` mode cannot be used with them. If you
357
357
  request `"jsonTool"` anyway, the provider falls back to `"outputFormat"` and
358
- emits a warning. Likewise, a `required` or named `toolChoice` is downgraded to
358
+ emits a warning. The same fallback applies when these models are served through
359
+ providers that otherwise default to the JSON tool, such as the Amazon Bedrock
360
+ Anthropic and Google Vertex Anthropic providers. On those providers, schemas
361
+ that native structured outputs cannot represent keep the JSON tool:
362
+ self-referencing schemas, schemas with more than 10 optional properties, and
363
+ schemas with open objects such as maps (`additionalProperties` set to a schema
364
+ or `true`), which native structured outputs would return as `{}`.
365
+ Likewise, a `required` or named `toolChoice` is downgraded to
359
366
  `auto` with a warning; instruct the model to use the tool in your prompt and
360
367
  verify that a tool call was made.
361
368
 
369
+ When the JSON tool cannot be forced (for example with manual thinking) and the
370
+ model answers in text without calling it, the text of its final answer is
371
+ returned. Text before a tool call is still dropped.
372
+
362
373
  ### Effort
363
374
 
364
375
  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": "4.0.93",
3
+ "version": "4.0.94",
4
4
  "type": "module",
5
5
  "license": "Apache-2.0",
6
6
  "sideEffects": false,
@@ -77,6 +77,7 @@ import {
77
77
  import { CacheControlValidator } from './get-cache-control';
78
78
  import { mapAnthropicStopReason } from './map-anthropic-stop-reason';
79
79
  import { sanitizeJsonSchema } from './sanitize-json-schema';
80
+ import { supportsNativeStructuredOutputSchema } from './supports-native-structured-output-schema';
80
81
 
81
82
  function createAnthropicStreamError(error: {
82
83
  message: string;
@@ -279,6 +280,47 @@ export type AnthropicLanguageModelConfig = {
279
280
  cacheControlProviderOptionsName?: string;
280
281
  };
281
282
 
283
+ const TEXT_OR_THINKING_BLOCK_TYPES = new Set<string>([
284
+ 'redacted_thinking',
285
+ 'text',
286
+ 'thinking',
287
+ ]);
288
+
289
+ function isContinuationStopReason(
290
+ stopReason: string | null | undefined,
291
+ ): boolean {
292
+ return stopReason === 'pause_turn' || stopReason === 'tool_use';
293
+ }
294
+
295
+ /**
296
+ * Text parts to return when the JSON response tool is in use: the text after
297
+ * the last tool call or result, and only if the tool was never called.
298
+ */
299
+ function getJsonResponseToolFallbackText<
300
+ PART extends { type: string; name?: unknown },
301
+ >(
302
+ content: ReadonlyArray<PART>,
303
+ stopReason: string | null | undefined,
304
+ ): Set<PART> {
305
+ const text = new Set<PART>();
306
+ if (isContinuationStopReason(stopReason)) {
307
+ return text;
308
+ }
309
+
310
+ for (const part of content) {
311
+ if (part.type === 'tool_use' && part.name === 'json') {
312
+ return new Set();
313
+ }
314
+ if (part.type === 'text') {
315
+ text.add(part);
316
+ } else if (!TEXT_OR_THINKING_BLOCK_TYPES.has(part.type)) {
317
+ text.clear();
318
+ }
319
+ }
320
+
321
+ return text;
322
+ }
323
+
282
324
  export class AnthropicLanguageModel implements LanguageModelV4 {
283
325
  readonly specificationVersion = 'v4';
284
326
 
@@ -493,13 +535,15 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
493
535
 
494
536
  // The JSON response tool relies on forced tool use, which some models
495
537
  // reject (e.g. Opus 5.5, Fable 5.1). Fall back to native structured
496
- // outputs when the model supports them.
538
+ // outputs even when the provider disables them by default (Bedrock,
539
+ // Vertex), unless they would reject the schema or lose data from it.
497
540
  if (
498
541
  !useStructuredOutput &&
499
542
  rejectsForcedToolUse &&
500
- supportsStructuredOutput &&
543
+ modelSupportsStructuredOutput &&
501
544
  responseFormat?.type === 'json' &&
502
- responseFormat.schema != null
545
+ responseFormat.schema != null &&
546
+ supportsNativeStructuredOutputSchema(responseFormat.schema)
503
547
  ) {
504
548
  warnings.push({
505
549
  type: 'unsupported',
@@ -1310,11 +1354,20 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
1310
1354
  Array.isArray(part.content) &&
1311
1355
  part.content.length > 0,
1312
1356
  );
1357
+ // The JSON response tool call becomes the text, so other text is dropped,
1358
+ // unless the model never called the tool and answered in text instead.
1359
+ const jsonResponseToolFallbackText = usesJsonResponseTool
1360
+ ? getJsonResponseToolFallbackText(response.content, response.stop_reason)
1361
+ : undefined;
1362
+
1313
1363
  // map response content to content array
1314
1364
  for (const part of response.content) {
1315
1365
  switch (part.type) {
1316
1366
  case 'text': {
1317
- if (!usesJsonResponseTool) {
1367
+ if (
1368
+ jsonResponseToolFallbackText == null ||
1369
+ jsonResponseToolFallbackText.has(part)
1370
+ ) {
1318
1371
  const citations = part.citations ?? [];
1319
1372
 
1320
1373
  content.push({
@@ -2003,6 +2056,26 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
2003
2056
  let safeguardResults: AnthropicMessageMetadata['safeguardResults'];
2004
2057
  let container: AnthropicMessageMetadata['container'] | null = null;
2005
2058
  let isJsonResponseFromTool = false;
2059
+ // Text held back while the JSON response tool is in use: it is only the
2060
+ // answer if no tool block follows it and the model never calls the tool.
2061
+ type HeldBackText = { id: string; text: string; citations: Citation[] };
2062
+ const heldBackText: HeldBackText[] = [];
2063
+ const heldBackTextByIndex = new Map<number, HeldBackText>();
2064
+ const createTextEnd = (
2065
+ id: string,
2066
+ citations: Citation[],
2067
+ ): LanguageModelV4StreamPart => ({
2068
+ type: 'text-end',
2069
+ id,
2070
+ ...(citations.length > 0 && {
2071
+ citations: citations.map(citation =>
2072
+ mapAnthropicCitation(citation, citationDocuments),
2073
+ ),
2074
+ providerMetadata: {
2075
+ anthropic: { citations },
2076
+ } satisfies AnthropicTextProviderMetadata,
2077
+ }),
2078
+ });
2006
2079
  let hasWebSearchResults = false;
2007
2080
  const fallbackWebCitations: Citation[] = [];
2008
2081
  let isMessageOpen = false;
@@ -2080,11 +2153,23 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
2080
2153
 
2081
2154
  blockType = contentBlockType;
2082
2155
 
2156
+ // text before a tool call or result is preamble, not the answer:
2157
+ if (!TEXT_OR_THINKING_BLOCK_TYPES.has(contentBlockType)) {
2158
+ heldBackText.length = 0;
2159
+ }
2160
+
2083
2161
  switch (contentBlockType) {
2084
2162
  case 'text': {
2085
2163
  // when a json response tool is used, the tool call is returned as text,
2086
- // so we ignore the text content:
2164
+ // so the text content is held back until we know whether it was called:
2087
2165
  if (usesJsonResponseTool) {
2166
+ const entry = {
2167
+ id: String(value.index),
2168
+ text: '',
2169
+ citations: [],
2170
+ };
2171
+ heldBackText.push(entry);
2172
+ heldBackTextByIndex.set(value.index, entry);
2088
2173
  return;
2089
2174
  }
2090
2175
 
@@ -2663,20 +2748,12 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
2663
2748
 
2664
2749
  switch (contentBlock.type) {
2665
2750
  case 'text': {
2666
- controller.enqueue({
2667
- type: 'text-end',
2668
- id: String(value.index),
2669
- ...(contentBlock.citations.length > 0 && {
2670
- citations: contentBlock.citations.map(citation =>
2671
- mapAnthropicCitation(citation, citationDocuments),
2672
- ),
2673
- providerMetadata: {
2674
- anthropic: {
2675
- citations: contentBlock.citations,
2676
- },
2677
- } satisfies AnthropicTextProviderMetadata,
2678
- }),
2679
- });
2751
+ controller.enqueue(
2752
+ createTextEnd(
2753
+ String(value.index),
2754
+ contentBlock.citations,
2755
+ ),
2756
+ );
2680
2757
  break;
2681
2758
  }
2682
2759
 
@@ -2799,8 +2876,12 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
2799
2876
  switch (deltaType) {
2800
2877
  case 'text_delta': {
2801
2878
  // when a json response tool is used, the tool call is returned as text,
2802
- // so we ignore the text content:
2879
+ // so the text content is held back until we know whether it was called:
2803
2880
  if (usesJsonResponseTool) {
2881
+ const entry = heldBackTextByIndex.get(value.index);
2882
+ if (entry != null) {
2883
+ entry.text += value.delta.text;
2884
+ }
2804
2885
  return; // excluding the text-start will also exclude the text-end
2805
2886
  }
2806
2887
 
@@ -2914,6 +2995,9 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
2914
2995
  if (contentBlock?.type === 'text') {
2915
2996
  contentBlock.citations.push(citation);
2916
2997
  }
2998
+ heldBackTextByIndex
2999
+ .get(value.index)
3000
+ ?.citations.push(citation);
2917
3001
 
2918
3002
  if (citation.type === 'web_search_result_location') {
2919
3003
  fallbackWebCitations.push(citation);
@@ -2959,6 +3043,7 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
2959
3043
 
2960
3044
  isMessageOpen = true;
2961
3045
  activeMessageId = value.message.id;
3046
+ heldBackTextByIndex.clear();
2962
3047
 
2963
3048
  usage.input_tokens = value.message.usage.input_tokens;
2964
3049
  usage.cache_read_input_tokens =
@@ -3193,6 +3278,22 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
3193
3278
  }
3194
3279
  }
3195
3280
  fallbackWebCitations.length = 0;
3281
+
3282
+ // a message that hands off to a tool or continuation is not the
3283
+ // final answer, so its held-back text is discarded:
3284
+ if (
3285
+ !isJsonResponseFromTool &&
3286
+ !isContinuationStopReason(finishReason.raw)
3287
+ ) {
3288
+ for (const { id, text, citations } of heldBackText) {
3289
+ if (text.length === 0) continue;
3290
+ controller.enqueue({ type: 'text-start', id });
3291
+ controller.enqueue({ type: 'text-delta', id, delta: text });
3292
+ controller.enqueue(createTextEnd(id, citations));
3293
+ }
3294
+ }
3295
+ heldBackText.length = 0;
3296
+
3196
3297
  controller.enqueue({
3197
3298
  type: 'finish',
3198
3299
  finishReason,
@@ -0,0 +1,115 @@
1
+ import type { JSONSchema7 } from '@ai-sdk/provider';
2
+ import { isRecord } from '@ai-sdk/provider-utils';
3
+
4
+ // Keywords whose values are maps of name -> schema rather than a schema.
5
+ const SCHEMA_MAP_KEYWORDS = new Set([
6
+ 'dependentSchemas',
7
+ 'patternProperties',
8
+ 'properties',
9
+ ]);
10
+
11
+ // Keywords holding instance data, which can contain a literal `$ref` key.
12
+ const DATA_KEYWORDS = new Set(['const', 'default', 'enum', 'examples']);
13
+
14
+ /**
15
+ * Whether a JSON schema contains a cycle of local `$ref`s, e.g. a tree node
16
+ * whose children reference the node itself.
17
+ *
18
+ * Anthropic's native structured outputs reject such schemas, while the JSON
19
+ * response tool accepts them.
20
+ */
21
+ export function hasCircularSchemaReference(schema: JSONSchema7): boolean {
22
+ const visiting = new Set<string>();
23
+ const visited = new Set<string>();
24
+
25
+ const visit = (pointer: string): boolean => {
26
+ if (visiting.has(pointer)) {
27
+ return true;
28
+ }
29
+ if (visited.has(pointer)) {
30
+ return false;
31
+ }
32
+
33
+ const target = resolveSchemaPointer(schema, pointer);
34
+ if (target === undefined) {
35
+ return false;
36
+ }
37
+
38
+ visiting.add(pointer);
39
+ const isCircular = collectLocalRefs(target).some(visit);
40
+ visiting.delete(pointer);
41
+ visited.add(pointer);
42
+
43
+ return isCircular;
44
+ };
45
+
46
+ return visit('#');
47
+ }
48
+
49
+ function collectLocalRefs(node: unknown, refs: string[] = []): string[] {
50
+ if (Array.isArray(node)) {
51
+ for (const item of node) {
52
+ collectLocalRefs(item, refs);
53
+ }
54
+ return refs;
55
+ }
56
+
57
+ if (!isRecord(node)) {
58
+ return refs;
59
+ }
60
+
61
+ for (const [key, value] of Object.entries(node)) {
62
+ if (key === '$ref') {
63
+ if (typeof value === 'string' && value.startsWith('#')) {
64
+ refs.push(value);
65
+ }
66
+ } else if (SCHEMA_MAP_KEYWORDS.has(key) && isRecord(value)) {
67
+ for (const subschema of Object.values(value)) {
68
+ collectLocalRefs(subschema, refs);
69
+ }
70
+ } else if (
71
+ // definitions are only reached through a `$ref`:
72
+ key !== '$defs' &&
73
+ key !== 'definitions' &&
74
+ !DATA_KEYWORDS.has(key)
75
+ ) {
76
+ collectLocalRefs(value, refs);
77
+ }
78
+ }
79
+
80
+ return refs;
81
+ }
82
+
83
+ export function resolveSchemaPointer(
84
+ schema: JSONSchema7,
85
+ pointer: string,
86
+ ): unknown {
87
+ if (pointer === '#') {
88
+ return schema;
89
+ }
90
+ if (!pointer.startsWith('#/')) {
91
+ return undefined;
92
+ }
93
+
94
+ let decodedPointer: string;
95
+ try {
96
+ decodedPointer = decodeURIComponent(pointer.slice(2));
97
+ } catch {
98
+ return undefined;
99
+ }
100
+
101
+ let current: unknown = schema;
102
+ for (const rawSegment of decodedPointer.split('/')) {
103
+ const segment = rawSegment.replace(/~1/g, '/').replace(/~0/g, '~');
104
+
105
+ if (Array.isArray(current)) {
106
+ current = current[Number(segment)];
107
+ } else if (isRecord(current)) {
108
+ current = current[segment];
109
+ } else {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ return current;
115
+ }
@@ -54,12 +54,11 @@ function sanitizeDefinition(
54
54
 
55
55
  function sanitizeSchema(schema: JSONSchema7): JSONSchema7 {
56
56
  const result: JSONSchema7 = {};
57
- const schemaWithDefs = schema as JSONSchema7 & {
58
- $defs?: Record<string, JSONSchema7Definition>;
59
- };
60
57
 
58
+ // Keep the definitions next to a `$ref` so a top-level reference still
59
+ // resolves (e.g. `{ $ref: '#/definitions/Answer', definitions: {...} }`).
61
60
  if (schema.$ref != null) {
62
- return { $ref: schema.$ref };
61
+ return { $ref: schema.$ref, ...sanitizeSchemaDefinitions(schema) };
63
62
  }
64
63
 
65
64
  if (schema.$schema != null) {
@@ -104,26 +103,7 @@ function sanitizeSchema(schema: JSONSchema7): JSONSchema7 {
104
103
  result.allOf = schema.allOf.map(sanitizeDefinition);
105
104
  }
106
105
 
107
- if (schema.definitions != null) {
108
- result.definitions = Object.fromEntries(
109
- Object.entries(schema.definitions).map(([name, definition]) => [
110
- name,
111
- sanitizeDefinition(definition),
112
- ]),
113
- );
114
- }
115
-
116
- if (schemaWithDefs.$defs != null) {
117
- const resultWithDefs = result as JSONSchema7 & {
118
- $defs?: Record<string, JSONSchema7Definition>;
119
- };
120
- resultWithDefs.$defs = Object.fromEntries(
121
- Object.entries(schemaWithDefs.$defs).map(([name, definition]) => [
122
- name,
123
- sanitizeDefinition(definition),
124
- ]),
125
- );
126
- }
106
+ Object.assign(result, sanitizeSchemaDefinitions(schema));
127
107
 
128
108
  if (schema.type === 'object' || schema.properties != null) {
129
109
  if (schema.properties != null) {
@@ -166,6 +146,29 @@ function sanitizeSchema(schema: JSONSchema7): JSONSchema7 {
166
146
  return result;
167
147
  }
168
148
 
149
+ function sanitizeSchemaDefinitions(schema: JSONSchema7): Pick<
150
+ JSONSchema7,
151
+ 'definitions'
152
+ > & {
153
+ $defs?: Record<string, JSONSchema7Definition>;
154
+ } {
155
+ const { $defs } = schema as { $defs?: Record<string, JSONSchema7Definition> };
156
+ const sanitizeAll = (definitions: Record<string, JSONSchema7Definition>) =>
157
+ Object.fromEntries(
158
+ Object.entries(definitions).map(([name, definition]) => [
159
+ name,
160
+ sanitizeDefinition(definition),
161
+ ]),
162
+ );
163
+
164
+ return {
165
+ ...(schema.definitions != null && {
166
+ definitions: sanitizeAll(schema.definitions),
167
+ }),
168
+ ...($defs != null && { $defs: sanitizeAll($defs) }),
169
+ };
170
+ }
171
+
169
172
  function getConstraintDescription(schema: JSONSchema7): string | undefined {
170
173
  const descriptions = DESCRIPTION_CONSTRAINT_KEYS.flatMap(key => {
171
174
  const value = schema[key];
@@ -0,0 +1,138 @@
1
+ import type { JSONSchema7 } from '@ai-sdk/provider';
2
+ import { isRecord } from '@ai-sdk/provider-utils';
3
+ import {
4
+ hasCircularSchemaReference,
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
+ }