@velum-labs/routekit-gateway 1.0.7 → 1.0.8

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.
@@ -135,6 +135,114 @@ function anthropicToolChoice(choice, parallelToolCalls) {
135
135
  ? { type: "tool", name: fn.name, ...disableParallel }
136
136
  : undefined;
137
137
  }
138
+ const ANTHROPIC_SUPPORTED_SCHEMA_KEYWORDS = new Set([
139
+ "$defs",
140
+ "$ref",
141
+ "additionalProperties",
142
+ "allOf",
143
+ "anyOf",
144
+ "const",
145
+ "description",
146
+ "enum",
147
+ "format",
148
+ "items",
149
+ "minItems",
150
+ "oneOf",
151
+ "pattern",
152
+ "properties",
153
+ "required",
154
+ "title",
155
+ "type"
156
+ ]);
157
+ const ANTHROPIC_SUPPORTED_STRING_FORMATS = new Set([
158
+ "date",
159
+ "date-time",
160
+ "duration",
161
+ "email",
162
+ "hostname",
163
+ "ipv4",
164
+ "ipv6",
165
+ "time",
166
+ "uri",
167
+ "uuid"
168
+ ]);
169
+ function isRecord(value) {
170
+ return typeof value === "object" && value !== null && !Array.isArray(value);
171
+ }
172
+ function cloneSchemaValue(value) {
173
+ if (Array.isArray(value))
174
+ return value.map(cloneSchemaValue);
175
+ if (!isRecord(value))
176
+ return value;
177
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, cloneSchemaValue(child)]));
178
+ }
179
+ function sanitizedSchemaMap(value) {
180
+ if (!isRecord(value))
181
+ return cloneSchemaValue(value);
182
+ return Object.fromEntries(Object.entries(value).map(([key, schema]) => [key, anthropicStructuredOutputSchema(schema)]));
183
+ }
184
+ /**
185
+ * Anthropic structured outputs accept only a subset of JSON Schema. Match the
186
+ * documented SDK behavior by retaining that subset, converting `oneOf` to
187
+ * `anyOf`, and moving omitted constraints into descriptions for model guidance.
188
+ * Callers remain responsible for validating the parsed response against the
189
+ * original schema.
190
+ */
191
+ function anthropicStructuredOutputSchema(schema) {
192
+ if (!isRecord(schema))
193
+ return cloneSchemaValue(schema);
194
+ if (typeof schema.$ref === "string")
195
+ return { $ref: schema.$ref };
196
+ const sanitized = {};
197
+ const deferredConstraints = [];
198
+ let oneOf;
199
+ for (const [key, value] of Object.entries(schema)) {
200
+ if (!ANTHROPIC_SUPPORTED_SCHEMA_KEYWORDS.has(key) ||
201
+ (key === "minItems" && value !== 0 && value !== 1) ||
202
+ (key === "format" &&
203
+ (typeof value !== "string" || !ANTHROPIC_SUPPORTED_STRING_FORMATS.has(value)))) {
204
+ deferredConstraints.push([key, cloneSchemaValue(value)]);
205
+ continue;
206
+ }
207
+ if (key === "$defs" || key === "properties") {
208
+ sanitized[key] = sanitizedSchemaMap(value);
209
+ continue;
210
+ }
211
+ if ((key === "allOf" || key === "anyOf") && Array.isArray(value)) {
212
+ sanitized[key] = value.map(anthropicStructuredOutputSchema);
213
+ continue;
214
+ }
215
+ if (key === "oneOf") {
216
+ oneOf = value;
217
+ continue;
218
+ }
219
+ if (key === "additionalProperties" || key === "items") {
220
+ sanitized[key] = Array.isArray(value)
221
+ ? value.map(anthropicStructuredOutputSchema)
222
+ : anthropicStructuredOutputSchema(value);
223
+ continue;
224
+ }
225
+ sanitized[key] = cloneSchemaValue(value);
226
+ }
227
+ if (oneOf !== undefined) {
228
+ if (!Object.hasOwn(sanitized, "anyOf") && Array.isArray(oneOf)) {
229
+ sanitized.anyOf = oneOf.map(anthropicStructuredOutputSchema);
230
+ }
231
+ else {
232
+ deferredConstraints.push(["oneOf", cloneSchemaValue(oneOf)]);
233
+ }
234
+ }
235
+ if (deferredConstraints.length > 0) {
236
+ const constraintDescription = `{${deferredConstraints
237
+ .map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
238
+ .join(", ")}}`;
239
+ sanitized.description =
240
+ typeof sanitized.description === "string" && sanitized.description.length > 0
241
+ ? `${sanitized.description}\n\n${constraintDescription}`
242
+ : constraintDescription;
243
+ }
244
+ return sanitized;
245
+ }
138
246
  function anthropicStructuredOutputFormat(responseFormat) {
139
247
  if (responseFormat?.type !== "json_schema" ||
140
248
  typeof responseFormat.json_schema?.schema !== "object" ||
@@ -143,7 +251,22 @@ function anthropicStructuredOutputFormat(responseFormat) {
143
251
  }
144
252
  return {
145
253
  type: "json_schema",
146
- schema: responseFormat.json_schema.schema
254
+ schema: anthropicStructuredOutputSchema(responseFormat.json_schema.schema)
255
+ };
256
+ }
257
+ function sanitizedAnthropicOutputConfig(outputConfig) {
258
+ if (outputConfig === undefined)
259
+ return undefined;
260
+ const format = outputConfig.format;
261
+ if (!isRecord(format) || format.type !== "json_schema" || !isRecord(format.schema)) {
262
+ return outputConfig;
263
+ }
264
+ return {
265
+ ...outputConfig,
266
+ format: {
267
+ ...format,
268
+ schema: anthropicStructuredOutputSchema(format.schema)
269
+ }
147
270
  };
148
271
  }
149
272
  export function anthropicMessages(body, model) {
@@ -211,7 +334,7 @@ export function anthropicMessages(body, model) {
211
334
  const translatedOutput = selection.mode === "effort" ? { effort: selection.effort } : undefined;
212
335
  const thinking = metadata?.thinking ?? translatedThinking;
213
336
  const structuredOutputFormat = anthropicStructuredOutputFormat(body.response_format);
214
- const outputConfig = metadata?.output_config != null
337
+ const outputConfig = sanitizedAnthropicOutputConfig(metadata?.output_config != null
215
338
  ? {
216
339
  ...metadata.output_config,
217
340
  ...(structuredOutputFormat !== undefined &&
@@ -224,7 +347,7 @@ export function anthropicMessages(body, model) {
224
347
  ...translatedOutput,
225
348
  ...(structuredOutputFormat !== undefined ? { format: structuredOutputFormat } : {})
226
349
  }
227
- : undefined;
350
+ : undefined);
228
351
  const toolChoice = anthropicToolChoice(body.tool_choice, body.parallel_tool_calls);
229
352
  return {
230
353
  model,
@@ -246,7 +369,7 @@ export function anthropicMessages(body, model) {
246
369
  {
247
370
  name: tool.function.name,
248
371
  description: tool.function.description,
249
- input_schema: tool.function.parameters ?? { type: "object" }
372
+ input_schema: anthropicStructuredOutputSchema(tool.function.parameters ?? { type: "object" })
250
373
  }
251
374
  ])
252
375
  }
@@ -35,6 +35,177 @@ test("Responses JSON schemas reach Anthropic as native structured output formats
35
35
  }
36
36
  });
37
37
  });
38
+ test("Anthropic structured outputs defer unsupported JSON Schema constraints", () => {
39
+ const schema = {
40
+ type: "object",
41
+ additionalProperties: false,
42
+ required: ["minimum", "scores", "labels"],
43
+ properties: {
44
+ minimum: { type: "integer", minimum: 1, maximum: 16_384 },
45
+ scores: {
46
+ type: "array",
47
+ minItems: 20,
48
+ maxItems: 20,
49
+ items: { type: "number", minimum: 0, maximum: 1 }
50
+ },
51
+ labels: {
52
+ type: "array",
53
+ minItems: 1,
54
+ items: { type: "string", minLength: 1, maxLength: 128 }
55
+ }
56
+ }
57
+ };
58
+ const original = structuredClone(schema);
59
+ const outbound = anthropicMessages({
60
+ model: "claude-opus-5",
61
+ messages: [{ role: "user", content: "author evaluations" }],
62
+ response_format: {
63
+ type: "json_schema",
64
+ json_schema: { name: "routekit_evaluations", schema, strict: true }
65
+ }
66
+ }, "claude-opus-5");
67
+ assert.deepEqual(schema, original);
68
+ assert.deepEqual(outbound.output_config, {
69
+ format: {
70
+ type: "json_schema",
71
+ schema: {
72
+ type: "object",
73
+ additionalProperties: false,
74
+ required: ["minimum", "scores", "labels"],
75
+ properties: {
76
+ minimum: {
77
+ type: "integer",
78
+ description: "{minimum: 1, maximum: 16384}"
79
+ },
80
+ scores: {
81
+ type: "array",
82
+ items: {
83
+ type: "number",
84
+ description: "{minimum: 0, maximum: 1}"
85
+ },
86
+ description: "{minItems: 20, maxItems: 20}"
87
+ },
88
+ labels: {
89
+ type: "array",
90
+ minItems: 1,
91
+ items: {
92
+ type: "string",
93
+ description: "{minLength: 1, maxLength: 128}"
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ });
100
+ const nativeOutbound = anthropicMessages(anthropicToChat({
101
+ model: "claude-opus-5",
102
+ messages: [{ role: "user", content: "author evaluations" }],
103
+ output_config: {
104
+ format: { type: "json_schema", schema }
105
+ }
106
+ }, "claude-opus-5"), "claude-opus-5");
107
+ assert.deepEqual(nativeOutbound.output_config, outbound.output_config);
108
+ });
109
+ test("Anthropic tool input schemas defer unsupported JSON Schema constraints", () => {
110
+ const parameters = {
111
+ type: "object",
112
+ additionalProperties: false,
113
+ required: ["limit", "scores"],
114
+ properties: {
115
+ limit: { type: "integer", minimum: 1, maximum: 16_384 },
116
+ scores: {
117
+ type: "array",
118
+ minItems: 20,
119
+ maxItems: 20,
120
+ items: { type: "number", minimum: 0, maximum: 1 }
121
+ }
122
+ }
123
+ };
124
+ const original = structuredClone(parameters);
125
+ const outbound = anthropicMessages({
126
+ model: "claude-opus-5",
127
+ messages: [{ role: "user", content: "score the inputs" }],
128
+ tools: [
129
+ {
130
+ type: "function",
131
+ function: {
132
+ name: "score_inputs",
133
+ description: "score structured inputs",
134
+ parameters
135
+ }
136
+ }
137
+ ]
138
+ }, "claude-opus-5");
139
+ assert.deepEqual(parameters, original);
140
+ assert.deepEqual(outbound.tools, [
141
+ {
142
+ name: "score_inputs",
143
+ description: "score structured inputs",
144
+ input_schema: {
145
+ type: "object",
146
+ additionalProperties: false,
147
+ required: ["limit", "scores"],
148
+ properties: {
149
+ limit: {
150
+ type: "integer",
151
+ description: "{minimum: 1, maximum: 16384}"
152
+ },
153
+ scores: {
154
+ type: "array",
155
+ items: {
156
+ type: "number",
157
+ description: "{minimum: 0, maximum: 1}"
158
+ },
159
+ description: "{minItems: 20, maxItems: 20}"
160
+ }
161
+ }
162
+ }
163
+ }
164
+ ]);
165
+ });
166
+ test("Anthropic structured outputs keep only supported schema keywords and formats", () => {
167
+ const outbound = anthropicMessages({
168
+ model: "claude-opus-5",
169
+ messages: [{ role: "user", content: "return a structured value" }],
170
+ response_format: {
171
+ type: "json_schema",
172
+ json_schema: {
173
+ name: "routekit_supported_schema",
174
+ strict: true,
175
+ schema: {
176
+ type: "object",
177
+ minProperties: 1,
178
+ properties: {
179
+ identifier: {
180
+ type: "string",
181
+ pattern: "^[a-z]+$",
182
+ format: "regex"
183
+ },
184
+ createdAt: {
185
+ type: "string",
186
+ format: "date-time"
187
+ },
188
+ choice: {
189
+ oneOf: [{ const: "one" }, { const: "two" }]
190
+ }
191
+ },
192
+ required: ["identifier", "createdAt", "choice"],
193
+ additionalProperties: false
194
+ }
195
+ }
196
+ }
197
+ }, "claude-opus-5");
198
+ const schema = outbound.output_config.format.schema;
199
+ const properties = schema.properties;
200
+ assert.equal(schema.minProperties, undefined);
201
+ assert.match(schema.description, /minProperties/u);
202
+ assert.equal(properties.identifier.format, undefined);
203
+ assert.equal(properties.identifier.pattern, "^[a-z]+$");
204
+ assert.match(properties.identifier.description, /format/u);
205
+ assert.equal(properties.createdAt.format, "date-time");
206
+ assert.equal(properties.choice.oneOf, undefined);
207
+ assert.deepEqual(properties.choice.anyOf, [{ const: "one" }, { const: "two" }]);
208
+ });
38
209
  test("direct provider backends reject malformed reasoning controls before transport", async () => {
39
210
  const cases = [
40
211
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@velum-labs/routekit-gateway",
3
3
  "private": false,
4
- "version": "1.0.7",
4
+ "version": "1.0.8",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/routekit.git",
@@ -45,12 +45,12 @@
45
45
  "@aws-sdk/client-bedrock": "3.1095.0",
46
46
  "@aws-sdk/client-bedrock-runtime": "3.1095.0",
47
47
  "effect": "4.0.0-rc.108",
48
- "@velum-labs/routekit-config-core": "1.0.7",
49
- "@velum-labs/routekit-contracts": "1.0.7",
50
- "@velum-labs/routekit-eval-contracts": "1.0.7",
51
- "@velum-labs/routekit-eval-core": "1.0.7",
52
- "@velum-labs/routekit-registry": "1.0.7",
53
- "@velum-labs/routekit-runtime": "1.0.7"
48
+ "@velum-labs/routekit-config-core": "1.0.8",
49
+ "@velum-labs/routekit-contracts": "1.0.8",
50
+ "@velum-labs/routekit-eval-contracts": "1.0.8",
51
+ "@velum-labs/routekit-eval-core": "1.0.8",
52
+ "@velum-labs/routekit-registry": "1.0.8",
53
+ "@velum-labs/routekit-runtime": "1.0.8"
54
54
  },
55
55
  "keywords": [
56
56
  "routekit",