@velum-labs/routekit-gateway 1.0.6 → 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
  }
@@ -18,7 +18,7 @@ export type BedrockProviderSourceOptions = {
18
18
  mantleBackend?: BedrockMantleBackend;
19
19
  };
20
20
  export { fromBedrockConverseOutput, toBedrockConverseInput };
21
- export declare const BEDROCK_OPENAI_ALLOWLIST: readonly ["openai.gpt-5.4", "openai.gpt-5.5", "openai.gpt-5.6-sol", "openai.gpt-5.6-terra", "openai.gpt-5.6-luna"];
21
+ export declare const BEDROCK_OPENAI_ALLOWLIST: readonly ["openai.gpt-5.6-sol", "openai.gpt-5.6-terra", "openai.gpt-5.6-luna"];
22
22
  export declare function isBedrockOpenAiModel(modelId: string): boolean;
23
23
  /**
24
24
  * Remove OpenAI/Codex protocol fields that bedrock-mantle rejects while
@@ -12,8 +12,6 @@ import { OpenAiBackend } from "./openai-backend.js";
12
12
  import { gatewayTry, gatewayTryPromise } from "../effect/gateway.js";
13
13
  export { fromBedrockConverseOutput, toBedrockConverseInput };
14
14
  export const BEDROCK_OPENAI_ALLOWLIST = [
15
- "openai.gpt-5.4",
16
- "openai.gpt-5.5",
17
15
  "openai.gpt-5.6-sol",
18
16
  "openai.gpt-5.6-terra",
19
17
  "openai.gpt-5.6-luna"
@@ -126,13 +126,18 @@ test("Bedrock discovery includes active Anthropic foundations and paginated back
126
126
  assert.equal(commands.length, 3);
127
127
  });
128
128
  test("Bedrock OpenAI model ids stay on mantle and never match Anthropic Converse", () => {
129
- assert.equal(isBedrockOpenAiModel("openai.gpt-5.4"), true);
129
+ assert.equal(isBedrockOpenAiModel("openai.gpt-5.7"), true);
130
130
  assert.equal(isBedrockOpenAiModel("openai.gpt-5.6-sol"), true);
131
131
  assert.equal(isBedrockOpenAiModel("us.openai.gpt-5.6-terra"), true);
132
132
  assert.equal(isBedrockOpenAiModel("anthropic.claude-3"), false);
133
133
  assert.equal(isBedrockOpenAiModel("us.anthropic.claude-3"), false);
134
134
  });
135
- test("Bedrock discovery unions OpenAI mantle models when a bearer token is present", async () => {
135
+ test("Bedrock discovery advertises only verified OpenAI mantle models", async () => {
136
+ assert.deepEqual(BEDROCK_OPENAI_ALLOWLIST, [
137
+ "openai.gpt-5.6-sol",
138
+ "openai.gpt-5.6-terra",
139
+ "openai.gpt-5.6-luna"
140
+ ]);
136
141
  const source = new BedrockProviderSource({
137
142
  env: { AWS_BEARER_TOKEN_BEDROCK: "bedrock-key", AWS_REGION: "us-east-1" },
138
143
  controlClient: {
@@ -205,14 +210,14 @@ test("Bedrock routes OpenAI models through mantle and leaves Anthropic on Conver
205
210
  }
206
211
  }
207
212
  });
208
- assert.equal(source.responses.kind === "responses" && source.responses.supports("openai.gpt-5.4"), true);
213
+ assert.equal(source.responses.kind === "responses" && source.responses.supports("openai.gpt-5.6-sol"), true);
209
214
  assert.equal(source.responses.kind === "responses" && source.responses.supports("anthropic.claude-3"), false);
210
215
  assert.equal(source.capabilities.reasoningForModel("openai.gpt-5.6-sol")?.wireShape, "openai-responses");
211
216
  const openaiChat = await runRouteKitEffect(source.requests.chat({
212
- model: "openai.gpt-5.4",
217
+ model: "openai.gpt-5.6-sol",
213
218
  messages: [{ role: "user", content: "hi" }]
214
219
  }));
215
- assert.deepEqual(await openaiChat.json(), { id: "chat", model: "openai.gpt-5.4" });
220
+ assert.deepEqual(await openaiChat.json(), { id: "chat", model: "openai.gpt-5.6-sol" });
216
221
  assert.equal(source.responses.kind, "responses");
217
222
  if (source.responses.kind !== "responses")
218
223
  throw new Error("expected Responses support");
@@ -232,7 +237,7 @@ test("Bedrock routes OpenAI models through mantle and leaves Anthropic on Conver
232
237
  assert.equal(runtimeCalls.length, 1);
233
238
  assert.equal(runtimeCalls[0] instanceof ConverseCommand, true);
234
239
  assert.deepEqual(mantleCalls, [
235
- { kind: "chat", model: "openai.gpt-5.4" },
240
+ { kind: "chat", model: "openai.gpt-5.6-sol" },
236
241
  { kind: "responses", model: "openai.gpt-5.6-terra" }
237
242
  ]);
238
243
  const rejected = await runRouteKitEffect(source.responses.execute({
@@ -304,7 +309,7 @@ test("Bedrock OpenAI chat without a mantle key returns 400 and skips Converse",
304
309
  }
305
310
  });
306
311
  const response = await runRouteKitEffect(source.requests.chat({
307
- model: "openai.gpt-5.5",
312
+ model: "openai.gpt-5.6-luna",
308
313
  messages: [{ role: "user", content: "hi" }]
309
314
  }));
310
315
  assert.equal(response.status, 400);
@@ -134,7 +134,7 @@ test("GPT-5 Chat Completions translate max_tokens to max_completion_tokens", asy
134
134
  for (const model of [
135
135
  "gpt-5.6-sol",
136
136
  "openai.gpt-5.6-luna",
137
- "bedrock/openai.gpt-5.5",
137
+ "bedrock/openai.gpt-5.6-sol",
138
138
  "routekit/bedrock/us.openai.gpt-5.7"
139
139
  ]) {
140
140
  const response = await fetch(`${gateway.url()}/v1/chat/completions`, {
@@ -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.6",
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.6",
49
- "@velum-labs/routekit-contracts": "1.0.6",
50
- "@velum-labs/routekit-eval-contracts": "1.0.6",
51
- "@velum-labs/routekit-eval-core": "1.0.6",
52
- "@velum-labs/routekit-registry": "1.0.6",
53
- "@velum-labs/routekit-runtime": "1.0.6"
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",