@ai-sdk/anthropic 4.0.93 → 4.0.95
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.
- package/CHANGELOG.md +19 -0
- package/dist/index.js +240 -24
- package/dist/index.js.map +1 -1
- package/dist/internal/index.d.ts +12 -0
- package/dist/internal/index.d.ts.map +1 -1
- package/dist/internal/index.js +240 -24
- package/dist/internal/index.js.map +1 -1
- package/docs/05-anthropic.mdx +19 -2
- package/package.json +2 -2
- package/src/anthropic-language-model.ts +138 -22
- package/src/convert-to-anthropic-prompt.ts +17 -1
- package/src/has-circular-schema-reference.ts +115 -0
- package/src/internal/index.ts +1 -0
- package/src/sanitize-json-schema.ts +27 -24
- package/src/supports-native-structured-output-schema.ts +138 -0
package/docs/05-anthropic.mdx
CHANGED
|
@@ -290,7 +290,7 @@ The following optional provider options are available for Anthropic models:
|
|
|
290
290
|
Determines how structured outputs are generated. Optional.
|
|
291
291
|
- `"outputFormat"`: Use the `output_format` parameter to specify the structured output format.
|
|
292
292
|
- `"jsonTool"`: Use a special `"json"` tool to specify the structured output format.
|
|
293
|
-
- `"auto"`: Use `"outputFormat"` when
|
|
293
|
+
- `"auto"`: Use `"outputFormat"` when the model and schema support it, otherwise fall back to `"jsonTool"` (default). Schemas with recursive references, open objects, or more than ten optional properties use the JSON-tool fallback to avoid rejected requests or lost data.
|
|
294
294
|
|
|
295
295
|
- `metadata` _object_
|
|
296
296
|
|
|
@@ -355,10 +355,27 @@ 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.
|
|
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
|
+
The JSON-tool fallback limits which application tools are available:
|
|
370
|
+
`toolChoice: 'none'` sends only the internal JSON response tool, and a named tool
|
|
371
|
+
choice sends only that application tool alongside the JSON response tool. The
|
|
372
|
+
fallback can produce a JSON response without calling the named application tool;
|
|
373
|
+
verify the selected call if your application requires it.
|
|
374
|
+
|
|
375
|
+
When the JSON tool cannot be forced (for example with manual thinking) and the
|
|
376
|
+
model answers in text without calling it, the text of its final answer is
|
|
377
|
+
returned. Text before a tool call is still dropped.
|
|
378
|
+
|
|
362
379
|
### Effort
|
|
363
380
|
|
|
364
381
|
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.
|
|
3
|
+
"version": "4.0.95",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"sideEffects": false,
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@ai-sdk/provider": "4.0.35",
|
|
39
|
-
"@ai-sdk/provider-utils": "5.0.
|
|
39
|
+
"@ai-sdk/provider-utils": "5.0.73"
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@ai-sdk/test-server": "2.0.4",
|
|
@@ -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
|
|
|
@@ -482,6 +524,11 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
482
524
|
(config.supportsNativeStructuredOutput ?? true) &&
|
|
483
525
|
modelSupportsStructuredOutput;
|
|
484
526
|
|
|
527
|
+
const supportsResponseSchema =
|
|
528
|
+
responseFormat?.type !== 'json' ||
|
|
529
|
+
responseFormat.schema == null ||
|
|
530
|
+
supportsNativeStructuredOutputSchema(responseFormat.schema);
|
|
531
|
+
|
|
485
532
|
const supportsStrictTools =
|
|
486
533
|
(config.supportsStrictTools ?? true) && modelSupportsStructuredOutput;
|
|
487
534
|
|
|
@@ -489,17 +536,21 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
489
536
|
anthropicOptions?.structuredOutputMode ?? 'auto';
|
|
490
537
|
let useStructuredOutput =
|
|
491
538
|
structureOutputMode === 'outputFormat' ||
|
|
492
|
-
(structureOutputMode === 'auto' &&
|
|
539
|
+
(structureOutputMode === 'auto' &&
|
|
540
|
+
supportsStructuredOutput &&
|
|
541
|
+
supportsResponseSchema);
|
|
493
542
|
|
|
494
543
|
// The JSON response tool relies on forced tool use, which some models
|
|
495
544
|
// reject (e.g. Opus 5.5, Fable 5.1). Fall back to native structured
|
|
496
|
-
// outputs when the
|
|
545
|
+
// outputs even when the provider disables them by default (Bedrock,
|
|
546
|
+
// Vertex), unless they would reject the schema or lose data from it.
|
|
497
547
|
if (
|
|
498
548
|
!useStructuredOutput &&
|
|
499
549
|
rejectsForcedToolUse &&
|
|
500
|
-
|
|
550
|
+
modelSupportsStructuredOutput &&
|
|
501
551
|
responseFormat?.type === 'json' &&
|
|
502
|
-
responseFormat.schema != null
|
|
552
|
+
responseFormat.schema != null &&
|
|
553
|
+
supportsNativeStructuredOutputSchema(responseFormat.schema)
|
|
503
554
|
) {
|
|
504
555
|
warnings.push({
|
|
505
556
|
type: 'unsupported',
|
|
@@ -1090,7 +1141,15 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
1090
1141
|
} = await prepareTools(
|
|
1091
1142
|
jsonResponseTool != null
|
|
1092
1143
|
? {
|
|
1093
|
-
tools: [
|
|
1144
|
+
tools: [
|
|
1145
|
+
...(tools ?? []).filter(
|
|
1146
|
+
tool =>
|
|
1147
|
+
toolChoice?.type !== 'none' &&
|
|
1148
|
+
(toolChoice?.type !== 'tool' ||
|
|
1149
|
+
tool.name === toolChoice.toolName),
|
|
1150
|
+
),
|
|
1151
|
+
jsonResponseTool,
|
|
1152
|
+
],
|
|
1094
1153
|
toolChoice: { type: 'required' },
|
|
1095
1154
|
disableParallelToolUse: true,
|
|
1096
1155
|
cacheControlValidator,
|
|
@@ -1310,11 +1369,20 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
1310
1369
|
Array.isArray(part.content) &&
|
|
1311
1370
|
part.content.length > 0,
|
|
1312
1371
|
);
|
|
1372
|
+
// The JSON response tool call becomes the text, so other text is dropped,
|
|
1373
|
+
// unless the model never called the tool and answered in text instead.
|
|
1374
|
+
const jsonResponseToolFallbackText = usesJsonResponseTool
|
|
1375
|
+
? getJsonResponseToolFallbackText(response.content, response.stop_reason)
|
|
1376
|
+
: undefined;
|
|
1377
|
+
|
|
1313
1378
|
// map response content to content array
|
|
1314
1379
|
for (const part of response.content) {
|
|
1315
1380
|
switch (part.type) {
|
|
1316
1381
|
case 'text': {
|
|
1317
|
-
if (
|
|
1382
|
+
if (
|
|
1383
|
+
jsonResponseToolFallbackText == null ||
|
|
1384
|
+
jsonResponseToolFallbackText.has(part)
|
|
1385
|
+
) {
|
|
1318
1386
|
const citations = part.citations ?? [];
|
|
1319
1387
|
|
|
1320
1388
|
content.push({
|
|
@@ -2003,6 +2071,26 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
2003
2071
|
let safeguardResults: AnthropicMessageMetadata['safeguardResults'];
|
|
2004
2072
|
let container: AnthropicMessageMetadata['container'] | null = null;
|
|
2005
2073
|
let isJsonResponseFromTool = false;
|
|
2074
|
+
// Text held back while the JSON response tool is in use: it is only the
|
|
2075
|
+
// answer if no tool block follows it and the model never calls the tool.
|
|
2076
|
+
type HeldBackText = { id: string; text: string; citations: Citation[] };
|
|
2077
|
+
const heldBackText: HeldBackText[] = [];
|
|
2078
|
+
const heldBackTextByIndex = new Map<number, HeldBackText>();
|
|
2079
|
+
const createTextEnd = (
|
|
2080
|
+
id: string,
|
|
2081
|
+
citations: Citation[],
|
|
2082
|
+
): LanguageModelV4StreamPart => ({
|
|
2083
|
+
type: 'text-end',
|
|
2084
|
+
id,
|
|
2085
|
+
...(citations.length > 0 && {
|
|
2086
|
+
citations: citations.map(citation =>
|
|
2087
|
+
mapAnthropicCitation(citation, citationDocuments),
|
|
2088
|
+
),
|
|
2089
|
+
providerMetadata: {
|
|
2090
|
+
anthropic: { citations },
|
|
2091
|
+
} satisfies AnthropicTextProviderMetadata,
|
|
2092
|
+
}),
|
|
2093
|
+
});
|
|
2006
2094
|
let hasWebSearchResults = false;
|
|
2007
2095
|
const fallbackWebCitations: Citation[] = [];
|
|
2008
2096
|
let isMessageOpen = false;
|
|
@@ -2080,11 +2168,23 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
2080
2168
|
|
|
2081
2169
|
blockType = contentBlockType;
|
|
2082
2170
|
|
|
2171
|
+
// text before a tool call or result is preamble, not the answer:
|
|
2172
|
+
if (!TEXT_OR_THINKING_BLOCK_TYPES.has(contentBlockType)) {
|
|
2173
|
+
heldBackText.length = 0;
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2083
2176
|
switch (contentBlockType) {
|
|
2084
2177
|
case 'text': {
|
|
2085
2178
|
// when a json response tool is used, the tool call is returned as text,
|
|
2086
|
-
// so we
|
|
2179
|
+
// so the text content is held back until we know whether it was called:
|
|
2087
2180
|
if (usesJsonResponseTool) {
|
|
2181
|
+
const entry = {
|
|
2182
|
+
id: String(value.index),
|
|
2183
|
+
text: '',
|
|
2184
|
+
citations: [],
|
|
2185
|
+
};
|
|
2186
|
+
heldBackText.push(entry);
|
|
2187
|
+
heldBackTextByIndex.set(value.index, entry);
|
|
2088
2188
|
return;
|
|
2089
2189
|
}
|
|
2090
2190
|
|
|
@@ -2663,20 +2763,12 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
2663
2763
|
|
|
2664
2764
|
switch (contentBlock.type) {
|
|
2665
2765
|
case 'text': {
|
|
2666
|
-
controller.enqueue(
|
|
2667
|
-
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
),
|
|
2673
|
-
providerMetadata: {
|
|
2674
|
-
anthropic: {
|
|
2675
|
-
citations: contentBlock.citations,
|
|
2676
|
-
},
|
|
2677
|
-
} satisfies AnthropicTextProviderMetadata,
|
|
2678
|
-
}),
|
|
2679
|
-
});
|
|
2766
|
+
controller.enqueue(
|
|
2767
|
+
createTextEnd(
|
|
2768
|
+
String(value.index),
|
|
2769
|
+
contentBlock.citations,
|
|
2770
|
+
),
|
|
2771
|
+
);
|
|
2680
2772
|
break;
|
|
2681
2773
|
}
|
|
2682
2774
|
|
|
@@ -2799,8 +2891,12 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
2799
2891
|
switch (deltaType) {
|
|
2800
2892
|
case 'text_delta': {
|
|
2801
2893
|
// when a json response tool is used, the tool call is returned as text,
|
|
2802
|
-
// so we
|
|
2894
|
+
// so the text content is held back until we know whether it was called:
|
|
2803
2895
|
if (usesJsonResponseTool) {
|
|
2896
|
+
const entry = heldBackTextByIndex.get(value.index);
|
|
2897
|
+
if (entry != null) {
|
|
2898
|
+
entry.text += value.delta.text;
|
|
2899
|
+
}
|
|
2804
2900
|
return; // excluding the text-start will also exclude the text-end
|
|
2805
2901
|
}
|
|
2806
2902
|
|
|
@@ -2914,6 +3010,9 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
2914
3010
|
if (contentBlock?.type === 'text') {
|
|
2915
3011
|
contentBlock.citations.push(citation);
|
|
2916
3012
|
}
|
|
3013
|
+
heldBackTextByIndex
|
|
3014
|
+
.get(value.index)
|
|
3015
|
+
?.citations.push(citation);
|
|
2917
3016
|
|
|
2918
3017
|
if (citation.type === 'web_search_result_location') {
|
|
2919
3018
|
fallbackWebCitations.push(citation);
|
|
@@ -2959,6 +3058,7 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
2959
3058
|
|
|
2960
3059
|
isMessageOpen = true;
|
|
2961
3060
|
activeMessageId = value.message.id;
|
|
3061
|
+
heldBackTextByIndex.clear();
|
|
2962
3062
|
|
|
2963
3063
|
usage.input_tokens = value.message.usage.input_tokens;
|
|
2964
3064
|
usage.cache_read_input_tokens =
|
|
@@ -3193,6 +3293,22 @@ export class AnthropicLanguageModel implements LanguageModelV4 {
|
|
|
3193
3293
|
}
|
|
3194
3294
|
}
|
|
3195
3295
|
fallbackWebCitations.length = 0;
|
|
3296
|
+
|
|
3297
|
+
// a message that hands off to a tool or continuation is not the
|
|
3298
|
+
// final answer, so its held-back text is discarded:
|
|
3299
|
+
if (
|
|
3300
|
+
!isJsonResponseFromTool &&
|
|
3301
|
+
!isContinuationStopReason(finishReason.raw)
|
|
3302
|
+
) {
|
|
3303
|
+
for (const { id, text, citations } of heldBackText) {
|
|
3304
|
+
if (text.length === 0) continue;
|
|
3305
|
+
controller.enqueue({ type: 'text-start', id });
|
|
3306
|
+
controller.enqueue({ type: 'text-delta', id, delta: text });
|
|
3307
|
+
controller.enqueue(createTextEnd(id, citations));
|
|
3308
|
+
}
|
|
3309
|
+
}
|
|
3310
|
+
heldBackText.length = 0;
|
|
3311
|
+
|
|
3196
3312
|
controller.enqueue({
|
|
3197
3313
|
type: 'finish',
|
|
3198
3314
|
finishReason,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
type SharedV4ProviderMetadata,
|
|
10
10
|
} from '@ai-sdk/provider';
|
|
11
11
|
import {
|
|
12
|
+
stringifyWithBigIntAndCycles,
|
|
12
13
|
convertBase64ToUint8Array,
|
|
13
14
|
convertToBase64,
|
|
14
15
|
getTopLevelMediaType,
|
|
@@ -703,7 +704,7 @@ export async function convertToAnthropicPrompt({
|
|
|
703
704
|
case 'json':
|
|
704
705
|
case 'error-json':
|
|
705
706
|
default:
|
|
706
|
-
contentValue =
|
|
707
|
+
contentValue = stringifyWithBigIntAndCycles(output.value);
|
|
707
708
|
break;
|
|
708
709
|
}
|
|
709
710
|
|
|
@@ -1739,6 +1740,21 @@ function getAnthropicCaller(
|
|
|
1739
1740
|
|
|
1740
1741
|
// wrap invalid tool call input because Anthropic requires it to be an object
|
|
1741
1742
|
function toAnthropicToolInput(input: unknown): JSONObject {
|
|
1743
|
+
if (typeof input === 'string') {
|
|
1744
|
+
try {
|
|
1745
|
+
const parsed = secureJsonParse(input);
|
|
1746
|
+
if (
|
|
1747
|
+
typeof parsed === 'object' &&
|
|
1748
|
+
parsed !== null &&
|
|
1749
|
+
!Array.isArray(parsed)
|
|
1750
|
+
) {
|
|
1751
|
+
return parsed as JSONObject;
|
|
1752
|
+
}
|
|
1753
|
+
} catch {
|
|
1754
|
+
// malformed JSON falls through to rawInvalidInput wrapper
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1742
1758
|
return typeof input === 'object' && input !== null && !Array.isArray(input)
|
|
1743
1759
|
? (input as JSONObject)
|
|
1744
1760
|
: { rawInvalidInput: input as JSONValue };
|
|
@@ -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
|
+
}
|
package/src/internal/index.ts
CHANGED
|
@@ -13,4 +13,5 @@ export type {
|
|
|
13
13
|
} from '../anthropic-language-model-options';
|
|
14
14
|
export { prepareTools } from '../anthropic-prepare-tools';
|
|
15
15
|
export { sanitizeJsonSchema } from '../sanitize-json-schema';
|
|
16
|
+
export { supportsNativeStructuredOutputSchema } from '../supports-native-structured-output-schema';
|
|
16
17
|
export { AnthropicSkills } from '../skills/anthropic-skills';
|
|
@@ -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
|
-
|
|
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
|
+
}
|