@velum-labs/routekit-gateway 0.17.0 → 0.17.2
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/dist/adapters/anthropic.d.ts +20 -19
- package/dist/adapters/anthropic.js +91 -54
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/server.js +3 -3
- package/dist/test/anthropic.test.js +104 -118
- package/package.json +5 -5
|
@@ -104,8 +104,14 @@ export declare function openAiSseToAnthropic(upstream: ReadableStream<Uint8Array
|
|
|
104
104
|
export declare function countTokensEstimate(body: AnthropicRequest): number;
|
|
105
105
|
export declare function handleAnthropicMessages(backend: Backend, body: AnthropicRequest, modelCallId?: string, signal?: AbortSignal, backendOptions?: BackendRequestOptions): Promise<Response>;
|
|
106
106
|
export declare function handleCountTokens(body: AnthropicRequest): Response;
|
|
107
|
-
/**
|
|
107
|
+
/** Historical Claude Code discovery alias prefix. Never emitted by new installs. */
|
|
108
108
|
export declare const CLAUDE_ALIAS_PREFIX = "claude-";
|
|
109
|
+
/**
|
|
110
|
+
* Claude Code admits arbitrary custom entries in `availableModels` when they
|
|
111
|
+
* begin with `anthropic.`. The RouteKit discriminator makes the value
|
|
112
|
+
* reversible and reserves it from provider-native model spelling.
|
|
113
|
+
*/
|
|
114
|
+
export declare const CLAUDE_PICKER_PREFIX = "anthropic.routekit.";
|
|
109
115
|
export type ClaudePickerModelRoute = {
|
|
110
116
|
publicId: string;
|
|
111
117
|
nativeId: string;
|
|
@@ -121,20 +127,17 @@ export type ClaudeModelSelection = {
|
|
|
121
127
|
status: "unsupported_effort";
|
|
122
128
|
model: string;
|
|
123
129
|
message: string;
|
|
130
|
+
} | {
|
|
131
|
+
status: "ambiguous_model";
|
|
132
|
+
model: string;
|
|
133
|
+
message: string;
|
|
124
134
|
} | {
|
|
125
135
|
status: "passthrough";
|
|
126
136
|
model: string;
|
|
127
137
|
};
|
|
128
|
-
/**
|
|
129
|
-
* The id a model is advertised under in Claude Code's `/model` picker. Claude
|
|
130
|
-
* only lists ids beginning with `claude`/`anthropic`, so non-Anthropic models
|
|
131
|
-
* are aliased with a `claude-` prefix; the gateway maps the alias back when
|
|
132
|
-
* routing (see `resolveAlias`), and the picker shows the real id via
|
|
133
|
-
* `display_name`. This is the claude-code-router trick: the `model` field is an
|
|
134
|
-
* identifier we control end-to-end, so any model can be made selectable.
|
|
135
|
-
*/
|
|
138
|
+
/** Historical `claude-` spelling accepted for existing sessions only. */
|
|
136
139
|
export declare function claudeModelAlias(id: string): string;
|
|
137
|
-
/** Claude Code picker spelling for one catalog route. */
|
|
140
|
+
/** Claude Code's new native-picker spelling for one RouteKit catalog route. */
|
|
138
141
|
export declare function claudePickerClientModel(route: ClaudePickerModelRoute): string;
|
|
139
142
|
export declare function resolveClaudeModelAlias(requested: string | undefined, modelIds?: readonly string[], modelRoutes?: readonly ClaudePickerModelRoute[]): string | undefined;
|
|
140
143
|
/**
|
|
@@ -150,16 +153,14 @@ export declare function resolveClaudeModelSelection(requested: string | undefine
|
|
|
150
153
|
* defaults remain in force.
|
|
151
154
|
*/
|
|
152
155
|
export declare function withClaudeReasoningSelection(body: AnthropicRequest, selection: ReasoningSelection): AnthropicRequest;
|
|
153
|
-
/**
|
|
156
|
+
/** Historical effort-qualified spelling retained for compatibility exports. */
|
|
154
157
|
export declare function claudeEffortQualifiedModel(baseClientModel: string, selection: ReasoningSelection | undefined): string;
|
|
155
158
|
/**
|
|
156
|
-
* Anthropic-shaped `/v1/models`
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
* `
|
|
160
|
-
*
|
|
161
|
-
* advertised set (default model first); when absent we fall back to the
|
|
162
|
-
* single backend default.
|
|
159
|
+
* Anthropic-shaped `/v1/models` response. IDs remain canonical RouteKit ids:
|
|
160
|
+
* Claude Code filters arbitrary IDs from dynamic discovery, so RouteKit uses
|
|
161
|
+
* its supported `availableModels` custom-model path for the native picker.
|
|
162
|
+
* `modelIds` is the full advertised set (default model first); when absent we
|
|
163
|
+
* fall back to the single backend default.
|
|
163
164
|
*/
|
|
164
|
-
export declare function anthropicModelsResponse(backendModel: string | undefined, modelIds?: readonly string[],
|
|
165
|
+
export declare function anthropicModelsResponse(backendModel: string | undefined, modelIds?: readonly string[], _modelRoutes?: readonly ClaudePickerModelRoute[]): Response;
|
|
165
166
|
export {};
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* the request handler wires them to a `Backend` and returns a `Response` the
|
|
8
8
|
* server pipes straight to the client (JSON or SSE).
|
|
9
9
|
*/
|
|
10
|
-
import { EFFORT_QUALIFIED_MODEL_CODEC,
|
|
10
|
+
import { EFFORT_QUALIFIED_MODEL_CODEC, resolveModelEffortVariant } from "@velum-labs/routekit-contracts";
|
|
11
11
|
import { estimateTokens, randomId } from "@velum-labs/routekit-runtime";
|
|
12
12
|
import { SseDecoder, SseParseError } from "../sse/parse.js";
|
|
13
13
|
import { attachAnthropicMessageContent, attachAnthropicRequestMetadata, attachReasoningSelection, attachReasoningSelectionError, anthropicReasoningDetailsOf } from "./openai-chat-wire.js";
|
|
@@ -1120,29 +1120,29 @@ export async function handleAnthropicMessages(backend, body, modelCallId, signal
|
|
|
1120
1120
|
export function handleCountTokens(body) {
|
|
1121
1121
|
return jsonResponse(200, { input_tokens: countTokensEstimate(body) });
|
|
1122
1122
|
}
|
|
1123
|
-
/** Claude Code
|
|
1124
|
-
function isAnthropicFamilyId(id) {
|
|
1125
|
-
return id.startsWith("claude") || id.startsWith("anthropic");
|
|
1126
|
-
}
|
|
1127
|
-
/** The `claude-` prefix used to alias non-Anthropic models past Claude's filter. */
|
|
1123
|
+
/** Historical Claude Code discovery alias prefix. Never emitted by new installs. */
|
|
1128
1124
|
export const CLAUDE_ALIAS_PREFIX = "claude-";
|
|
1129
1125
|
/**
|
|
1130
|
-
*
|
|
1131
|
-
*
|
|
1132
|
-
*
|
|
1133
|
-
* routing (see `resolveAlias`), and the picker shows the real id via
|
|
1134
|
-
* `display_name`. This is the claude-code-router trick: the `model` field is an
|
|
1135
|
-
* identifier we control end-to-end, so any model can be made selectable.
|
|
1126
|
+
* Claude Code admits arbitrary custom entries in `availableModels` when they
|
|
1127
|
+
* begin with `anthropic.`. The RouteKit discriminator makes the value
|
|
1128
|
+
* reversible and reserves it from provider-native model spelling.
|
|
1136
1129
|
*/
|
|
1130
|
+
export const CLAUDE_PICKER_PREFIX = "anthropic.routekit.";
|
|
1131
|
+
/** Historical `claude-` spelling accepted for existing sessions only. */
|
|
1137
1132
|
export function claudeModelAlias(id) {
|
|
1138
|
-
return
|
|
1133
|
+
return id.startsWith("claude") || id.startsWith("anthropic")
|
|
1134
|
+
? id
|
|
1135
|
+
: `${CLAUDE_ALIAS_PREFIX}${id}`;
|
|
1139
1136
|
}
|
|
1140
|
-
/** Claude Code picker spelling for one catalog route. */
|
|
1137
|
+
/** Claude Code's new native-picker spelling for one RouteKit catalog route. */
|
|
1141
1138
|
export function claudePickerClientModel(route) {
|
|
1139
|
+
return `${CLAUDE_PICKER_PREFIX}${route.publicId}`;
|
|
1140
|
+
}
|
|
1141
|
+
function legacyClaudePickerClientModel(route) {
|
|
1142
1142
|
const displayName = route.provider === "claude-code" ? route.nativeId : route.publicId;
|
|
1143
1143
|
return claudeModelAlias(displayName);
|
|
1144
1144
|
}
|
|
1145
|
-
function
|
|
1145
|
+
function legacyClaudeVariantEntries(modelIds, modelRoutes) {
|
|
1146
1146
|
const routes = new Map(modelRoutes.map((route) => [route.publicId, route]));
|
|
1147
1147
|
return modelIds.map((publicId) => {
|
|
1148
1148
|
const route = routes.get(publicId) ?? {
|
|
@@ -1152,7 +1152,7 @@ function claudeVariantEntries(modelIds, modelRoutes) {
|
|
|
1152
1152
|
};
|
|
1153
1153
|
return {
|
|
1154
1154
|
model: publicId,
|
|
1155
|
-
clientModel:
|
|
1155
|
+
clientModel: legacyClaudePickerClientModel(route),
|
|
1156
1156
|
...(route.reasoning !== undefined ? { reasoning: route.reasoning } : {})
|
|
1157
1157
|
};
|
|
1158
1158
|
});
|
|
@@ -1161,7 +1161,30 @@ export function resolveClaudeModelAlias(requested, modelIds = [], modelRoutes =
|
|
|
1161
1161
|
if (requested === undefined)
|
|
1162
1162
|
return undefined;
|
|
1163
1163
|
const selection = resolveClaudeModelSelection(requested, modelIds, modelRoutes);
|
|
1164
|
-
return selection.status === "unsupported_effort"
|
|
1164
|
+
return selection.status === "unsupported_effort" || selection.status === "ambiguous_model"
|
|
1165
|
+
? undefined
|
|
1166
|
+
: selection.model;
|
|
1167
|
+
}
|
|
1168
|
+
function uniqueNativeModel(requested, modelRoutes) {
|
|
1169
|
+
const routes = new Map();
|
|
1170
|
+
for (const route of modelRoutes) {
|
|
1171
|
+
if (route.nativeId !== requested)
|
|
1172
|
+
continue;
|
|
1173
|
+
// Catalog aliases do not make an otherwise unique provider/native pair
|
|
1174
|
+
// ambiguous.
|
|
1175
|
+
const key = `${route.provider}\u0000${route.nativeId}`;
|
|
1176
|
+
if (!routes.has(key))
|
|
1177
|
+
routes.set(key, route);
|
|
1178
|
+
}
|
|
1179
|
+
const values = [...routes.values()];
|
|
1180
|
+
if (values.length === 0)
|
|
1181
|
+
return { status: "none" };
|
|
1182
|
+
if (values.length === 1)
|
|
1183
|
+
return { status: "resolved", model: values[0].publicId };
|
|
1184
|
+
return {
|
|
1185
|
+
status: "ambiguous",
|
|
1186
|
+
models: values.map((route) => route.publicId).sort((left, right) => left.localeCompare(right))
|
|
1187
|
+
};
|
|
1165
1188
|
}
|
|
1166
1189
|
/**
|
|
1167
1190
|
* Resolve a Claude Code picker id (base or effort-qualified) to the served
|
|
@@ -1172,15 +1195,44 @@ export function resolveClaudeModelSelection(requested, modelIds = [], modelRoute
|
|
|
1172
1195
|
return { status: "passthrough", model: "" };
|
|
1173
1196
|
}
|
|
1174
1197
|
if (modelIds.includes(requested)) {
|
|
1175
|
-
const route = modelRoutes.find((entry) => entry.publicId === requested);
|
|
1176
1198
|
return {
|
|
1177
1199
|
status: "resolved",
|
|
1178
1200
|
model: requested,
|
|
1179
|
-
clientModel:
|
|
1201
|
+
clientModel: requested,
|
|
1180
1202
|
selection: { mode: "auto" }
|
|
1181
1203
|
};
|
|
1182
1204
|
}
|
|
1183
|
-
|
|
1205
|
+
if (requested.startsWith(CLAUDE_PICKER_PREFIX)) {
|
|
1206
|
+
const candidate = requested.slice(CLAUDE_PICKER_PREFIX.length);
|
|
1207
|
+
if (modelIds.includes(candidate)) {
|
|
1208
|
+
return {
|
|
1209
|
+
status: "resolved",
|
|
1210
|
+
model: candidate,
|
|
1211
|
+
clientModel: requested,
|
|
1212
|
+
selection: { mode: "auto" }
|
|
1213
|
+
};
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
const native = uniqueNativeModel(requested, modelRoutes);
|
|
1217
|
+
if (native.status === "resolved") {
|
|
1218
|
+
return {
|
|
1219
|
+
status: "resolved",
|
|
1220
|
+
model: native.model,
|
|
1221
|
+
clientModel: requested,
|
|
1222
|
+
selection: { mode: "auto" }
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
if (native.status === "ambiguous") {
|
|
1226
|
+
return {
|
|
1227
|
+
status: "ambiguous_model",
|
|
1228
|
+
model: requested,
|
|
1229
|
+
message: `model "${requested}" is served by multiple RouteKit routes: ` +
|
|
1230
|
+
`${native.models.join(", ")}; select one of those canonical ids instead`
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
// New installs no longer create aliases or effort-qualified models. Preserve
|
|
1234
|
+
// parsing of the former `claude-*` spellings for existing sessions/scripts.
|
|
1235
|
+
const resolved = resolveModelEffortVariant(requested, legacyClaudeVariantEntries(modelIds, modelRoutes), EFFORT_QUALIFIED_MODEL_CODEC);
|
|
1184
1236
|
if (resolved.ok) {
|
|
1185
1237
|
return {
|
|
1186
1238
|
status: "resolved",
|
|
@@ -1231,52 +1283,37 @@ export function withClaudeReasoningSelection(body, selection) {
|
|
|
1231
1283
|
output_config: outputConfig
|
|
1232
1284
|
};
|
|
1233
1285
|
}
|
|
1234
|
-
/**
|
|
1286
|
+
/** Historical effort-qualified spelling retained for compatibility exports. */
|
|
1235
1287
|
export function claudeEffortQualifiedModel(baseClientModel, selection) {
|
|
1236
|
-
return
|
|
1288
|
+
return selection?.mode === "effort"
|
|
1289
|
+
? EFFORT_QUALIFIED_MODEL_CODEC.qualify(baseClientModel, selection.effort)
|
|
1290
|
+
: baseClientModel;
|
|
1237
1291
|
}
|
|
1238
1292
|
/**
|
|
1239
|
-
* Anthropic-shaped `/v1/models`
|
|
1240
|
-
*
|
|
1241
|
-
*
|
|
1242
|
-
* `
|
|
1243
|
-
*
|
|
1244
|
-
* advertised set (default model first); when absent we fall back to the
|
|
1245
|
-
* single backend default.
|
|
1293
|
+
* Anthropic-shaped `/v1/models` response. IDs remain canonical RouteKit ids:
|
|
1294
|
+
* Claude Code filters arbitrary IDs from dynamic discovery, so RouteKit uses
|
|
1295
|
+
* its supported `availableModels` custom-model path for the native picker.
|
|
1296
|
+
* `modelIds` is the full advertised set (default model first); when absent we
|
|
1297
|
+
* fall back to the single backend default.
|
|
1246
1298
|
*/
|
|
1247
|
-
export function anthropicModelsResponse(backendModel, modelIds,
|
|
1299
|
+
export function anthropicModelsResponse(backendModel, modelIds, _modelRoutes = []) {
|
|
1248
1300
|
const source = modelIds !== undefined && modelIds.length > 0
|
|
1249
1301
|
? modelIds
|
|
1250
1302
|
: backendModel !== undefined
|
|
1251
1303
|
? [backendModel]
|
|
1252
1304
|
: [];
|
|
1253
1305
|
const seen = new Set();
|
|
1254
|
-
const routes = new Map(modelRoutes.map((route) => [route.publicId, route]));
|
|
1255
1306
|
const models = [];
|
|
1256
1307
|
for (const realId of source) {
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
...(route.reasoning !== undefined ? { reasoning: route.reasoning } : {})
|
|
1267
|
-
}, EFFORT_QUALIFIED_MODEL_CODEC)) {
|
|
1268
|
-
if (seen.has(variant.id))
|
|
1269
|
-
continue;
|
|
1270
|
-
seen.add(variant.id);
|
|
1271
|
-
models.push({
|
|
1272
|
-
type: "model",
|
|
1273
|
-
id: variant.id,
|
|
1274
|
-
display_name: variant.selection.mode === "effort"
|
|
1275
|
-
? `${displayName} (${variant.selection.effort})`
|
|
1276
|
-
: displayName,
|
|
1277
|
-
created_at: new Date(0).toISOString()
|
|
1278
|
-
});
|
|
1279
|
-
}
|
|
1308
|
+
if (seen.has(realId))
|
|
1309
|
+
continue;
|
|
1310
|
+
seen.add(realId);
|
|
1311
|
+
models.push({
|
|
1312
|
+
type: "model",
|
|
1313
|
+
id: realId,
|
|
1314
|
+
display_name: realId,
|
|
1315
|
+
created_at: new Date(0).toISOString()
|
|
1316
|
+
});
|
|
1280
1317
|
}
|
|
1281
1318
|
const ids = models.map((model) => model.id);
|
|
1282
1319
|
return new Response(JSON.stringify({
|
package/dist/index.d.ts
CHANGED
|
@@ -21,7 +21,7 @@ export { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
|
|
|
21
21
|
export { ANTHROPIC_MESSAGE_CONTENT, ANTHROPIC_REQUEST_METADATA, REASONING_SELECTION, ROUTEKIT_EXTENSION_KEY, attachAnthropicMessageContent, attachAnthropicRequestMetadata, attachReasoningSelection, anthropicMessageContentOf, anthropicRequestMetadataOf, routeKitRequestValidationErrorOf, reasoningSelectionErrorOf, reasoningSelectionOf, responsesReasoningMetadataErrorOf, withoutRouteKitExtensions } from "./adapters/openai-chat-wire.js";
|
|
22
22
|
export type { AnthropicNativeContentBlock, AnthropicRequestMetadata, RouteKitMessageEnvelope, RouteKitReasoningEnvelope } from "./adapters/openai-chat-wire.js";
|
|
23
23
|
export { isCursorChatBody, translateCursorRequest } from "./adapters/cursor.js";
|
|
24
|
-
export { anthropicModelsResponse, anthropicToChat, CLAUDE_ALIAS_PREFIX, chatToAnthropicMessage, claudeEffortQualifiedModel, claudeModelAlias, claudePickerClientModel, countTokensEstimate, handleAnthropicMessages, handleCountTokens, mapStopReason, openAiSseToAnthropic, resolveClaudeModelAlias, resolveClaudeModelSelection, withClaudeReasoningSelection } from "./adapters/anthropic.js";
|
|
24
|
+
export { anthropicModelsResponse, anthropicToChat, CLAUDE_ALIAS_PREFIX, CLAUDE_PICKER_PREFIX, chatToAnthropicMessage, claudeEffortQualifiedModel, claudeModelAlias, claudePickerClientModel, countTokensEstimate, handleAnthropicMessages, handleCountTokens, mapStopReason, openAiSseToAnthropic, resolveClaudeModelAlias, resolveClaudeModelSelection, withClaudeReasoningSelection } from "./adapters/anthropic.js";
|
|
25
25
|
export type { AnthropicRequest, ClaudeModelSelection, ClaudePickerModelRoute } from "./adapters/anthropic.js";
|
|
26
26
|
export { chatToResponses, customToolNames, handleResponses, openAiSseToResponses, responsesToChat, responsesToolRegistry } from "./adapters/responses.js";
|
|
27
27
|
export type { ResponsesRequest, ResponsesToolKind, ResponsesToolRegistry } from "./adapters/responses.js";
|
package/dist/index.js
CHANGED
|
@@ -11,7 +11,7 @@ export { CapacityPool } from "./capacity-pool.js";
|
|
|
11
11
|
export { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
|
|
12
12
|
export { ANTHROPIC_MESSAGE_CONTENT, ANTHROPIC_REQUEST_METADATA, REASONING_SELECTION, ROUTEKIT_EXTENSION_KEY, attachAnthropicMessageContent, attachAnthropicRequestMetadata, attachReasoningSelection, anthropicMessageContentOf, anthropicRequestMetadataOf, routeKitRequestValidationErrorOf, reasoningSelectionErrorOf, reasoningSelectionOf, responsesReasoningMetadataErrorOf, withoutRouteKitExtensions } from "./adapters/openai-chat-wire.js";
|
|
13
13
|
export { isCursorChatBody, translateCursorRequest } from "./adapters/cursor.js";
|
|
14
|
-
export { anthropicModelsResponse, anthropicToChat, CLAUDE_ALIAS_PREFIX, chatToAnthropicMessage, claudeEffortQualifiedModel, claudeModelAlias, claudePickerClientModel, countTokensEstimate, handleAnthropicMessages, handleCountTokens, mapStopReason, openAiSseToAnthropic, resolveClaudeModelAlias, resolveClaudeModelSelection, withClaudeReasoningSelection } from "./adapters/anthropic.js";
|
|
14
|
+
export { anthropicModelsResponse, anthropicToChat, CLAUDE_ALIAS_PREFIX, CLAUDE_PICKER_PREFIX, chatToAnthropicMessage, claudeEffortQualifiedModel, claudeModelAlias, claudePickerClientModel, countTokensEstimate, handleAnthropicMessages, handleCountTokens, mapStopReason, openAiSseToAnthropic, resolveClaudeModelAlias, resolveClaudeModelSelection, withClaudeReasoningSelection } from "./adapters/anthropic.js";
|
|
15
15
|
export { chatToResponses, customToolNames, handleResponses, openAiSseToResponses, responsesToChat, responsesToolRegistry } from "./adapters/responses.js";
|
|
16
16
|
export { MAX_WEB_SEARCHES_PER_TURN, resolveWebSearchExecutor } from "./adapters/web-search.js";
|
|
17
17
|
export { DIALECT_DROPPED_ATTRIBUTE, droppedField, resetDroppedFieldWarnings, withDroppedFieldSpan } from "./adapters/dropped.js";
|
package/dist/server.js
CHANGED
|
@@ -348,7 +348,7 @@ export async function startGateway(options) {
|
|
|
348
348
|
if (method === "GET" && path.startsWith("/v1/models/")) {
|
|
349
349
|
const id = decodeURIComponent(path.slice("/v1/models/".length));
|
|
350
350
|
const selection = resolveClaudeSelection(backend, id);
|
|
351
|
-
if (selection.status === "unsupported_effort") {
|
|
351
|
+
if (selection.status === "unsupported_effort" || selection.status === "ambiguous_model") {
|
|
352
352
|
writeClaudeSelectionError(res, selection);
|
|
353
353
|
return;
|
|
354
354
|
}
|
|
@@ -489,7 +489,7 @@ export async function startGateway(options) {
|
|
|
489
489
|
return;
|
|
490
490
|
const rawBody = raw;
|
|
491
491
|
const selection = resolveClaudeSelection(backend, rawBody.model);
|
|
492
|
-
if (selection.status === "unsupported_effort") {
|
|
492
|
+
if (selection.status === "unsupported_effort" || selection.status === "ambiguous_model") {
|
|
493
493
|
writeClaudeSelectionError(res, selection);
|
|
494
494
|
return;
|
|
495
495
|
}
|
|
@@ -532,7 +532,7 @@ export async function startGateway(options) {
|
|
|
532
532
|
return;
|
|
533
533
|
const rawBody = raw;
|
|
534
534
|
const selection = resolveClaudeSelection(backend, rawBody.model);
|
|
535
|
-
if (selection.status === "unsupported_effort") {
|
|
535
|
+
if (selection.status === "unsupported_effort" || selection.status === "ambiguous_model") {
|
|
536
536
|
writeClaudeSelectionError(res, selection);
|
|
537
537
|
return;
|
|
538
538
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { createServer } from "node:http";
|
|
3
3
|
import { test } from "node:test";
|
|
4
|
-
import { anthropicModelsResponse, anthropicToChat, chatToAnthropicMessage,
|
|
4
|
+
import { anthropicModelsResponse, anthropicToChat, chatToAnthropicMessage, mapStopReason, openAiSseToAnthropic, resolveClaudeModelAlias, resolveClaudeModelSelection, withClaudeReasoningSelection } from "../adapters/anthropic.js";
|
|
5
5
|
import { OpenAiBackend } from "../backend.js";
|
|
6
6
|
import { CatalogBackend } from "../router.js";
|
|
7
7
|
import { MODEL_CALL_ID_HEADER } from "../provenance.js";
|
|
@@ -12,7 +12,7 @@ import { ANTHROPIC_MESSAGE_CONTENT, ANTHROPIC_REQUEST_METADATA } from "../adapte
|
|
|
12
12
|
* Verifies request translation (system, tools, tool results), non-streaming
|
|
13
13
|
* and streaming response shapes, count_tokens, and discovery.
|
|
14
14
|
*/
|
|
15
|
-
test("anthropicModelsResponse
|
|
15
|
+
test("anthropicModelsResponse keeps canonical route ids and emits no effort variants", async () => {
|
|
16
16
|
const res = anthropicModelsResponse("route-primary", [
|
|
17
17
|
"route-primary",
|
|
18
18
|
"claude-opus-4-8",
|
|
@@ -20,70 +20,17 @@ test("anthropicModelsResponse aliases every model past Claude Code's claude/anth
|
|
|
20
20
|
"mlx-community/Qwen3-1.7B-4bit"
|
|
21
21
|
]);
|
|
22
22
|
const body = (await res.json());
|
|
23
|
-
// Every id begins with claude/anthropic so Claude Code lists them all...
|
|
24
|
-
assert.ok(body.data.every((model) => model.id.startsWith("claude") || model.id.startsWith("anthropic")));
|
|
25
|
-
// ...non-Anthropic ids are aliased, Anthropic-family ids pass through as-is.
|
|
26
23
|
assert.deepEqual(body.data.map((model) => model.id), [
|
|
27
|
-
"
|
|
24
|
+
"route-primary",
|
|
28
25
|
"claude-opus-4-8",
|
|
29
|
-
"
|
|
30
|
-
"
|
|
26
|
+
"gpt-5.5",
|
|
27
|
+
"mlx-community/Qwen3-1.7B-4bit"
|
|
31
28
|
]);
|
|
32
|
-
|
|
33
|
-
const gpt = body.data.find((model) => model.id === "
|
|
29
|
+
assert.ok(body.data.every((model) => !model.id.includes(":")));
|
|
30
|
+
const gpt = body.data.find((model) => model.id === "gpt-5.5");
|
|
34
31
|
assert.equal(gpt?.display_name, "gpt-5.5");
|
|
35
|
-
assert.equal(claudeModelAlias("claude-opus-4-8"), "claude-opus-4-8");
|
|
36
|
-
assert.equal(resolveClaudeModelAlias("claude-gpt-5.5", [
|
|
37
|
-
"route-primary",
|
|
38
|
-
"gpt-5.5"
|
|
39
|
-
]), "gpt-5.5");
|
|
40
|
-
assert.equal(resolveClaudeModelAlias("claude-opus-4-8", ["gpt-5.5"]), "claude-opus-4-8");
|
|
41
|
-
});
|
|
42
|
-
test("anthropicModelsResponse exposes Claude subscription models as bare native ids", async () => {
|
|
43
|
-
const response = anthropicModelsResponse("claude-code/claude-sonnet-4-6", [
|
|
44
|
-
"claude-code/claude-sonnet-4-6",
|
|
45
|
-
"codex/gpt-5.5",
|
|
46
|
-
"anthropic/claude-opus-4-8"
|
|
47
|
-
], [
|
|
48
|
-
{
|
|
49
|
-
publicId: "claude-code/claude-sonnet-4-6",
|
|
50
|
-
nativeId: "claude-sonnet-4-6",
|
|
51
|
-
provider: "claude-code"
|
|
52
|
-
},
|
|
53
|
-
{
|
|
54
|
-
publicId: "codex/gpt-5.5",
|
|
55
|
-
nativeId: "gpt-5.5",
|
|
56
|
-
provider: "codex"
|
|
57
|
-
},
|
|
58
|
-
{
|
|
59
|
-
publicId: "anthropic/claude-opus-4-8",
|
|
60
|
-
nativeId: "claude-opus-4-8",
|
|
61
|
-
provider: "anthropic"
|
|
62
|
-
}
|
|
63
|
-
]);
|
|
64
|
-
const body = (await response.json());
|
|
65
|
-
assert.deepEqual(body.data, [
|
|
66
|
-
{
|
|
67
|
-
id: "claude-sonnet-4-6",
|
|
68
|
-
display_name: "claude-sonnet-4-6",
|
|
69
|
-
created_at: new Date(0).toISOString(),
|
|
70
|
-
type: "model"
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
id: "claude-codex/gpt-5.5",
|
|
74
|
-
display_name: "codex/gpt-5.5",
|
|
75
|
-
created_at: new Date(0).toISOString(),
|
|
76
|
-
type: "model"
|
|
77
|
-
},
|
|
78
|
-
{
|
|
79
|
-
id: "anthropic/claude-opus-4-8",
|
|
80
|
-
display_name: "anthropic/claude-opus-4-8",
|
|
81
|
-
created_at: new Date(0).toISOString(),
|
|
82
|
-
type: "model"
|
|
83
|
-
}
|
|
84
|
-
]);
|
|
85
32
|
});
|
|
86
|
-
test("
|
|
33
|
+
test("Claude selection accepts picker ids and unique native ids but rejects collisions", () => {
|
|
87
34
|
const reasoning = {
|
|
88
35
|
status: "supported",
|
|
89
36
|
efforts: [
|
|
@@ -93,49 +40,35 @@ test("anthropicModelsResponse emits base plus discovered effort variants", async
|
|
|
93
40
|
],
|
|
94
41
|
provenance: "provider"
|
|
95
42
|
};
|
|
96
|
-
|
|
97
|
-
{
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
provider: "claude-code",
|
|
101
|
-
reasoning
|
|
102
|
-
},
|
|
103
|
-
{
|
|
104
|
-
publicId: "codex/gpt-5.5",
|
|
105
|
-
nativeId: "gpt-5.5",
|
|
106
|
-
provider: "codex",
|
|
107
|
-
reasoning
|
|
108
|
-
},
|
|
109
|
-
{
|
|
110
|
-
publicId: "openai/gpt-4o",
|
|
111
|
-
nativeId: "gpt-4o",
|
|
112
|
-
provider: "openai"
|
|
113
|
-
}
|
|
114
|
-
]);
|
|
115
|
-
const body = (await response.json());
|
|
116
|
-
assert.deepEqual(body.data.map((model) => model.id), [
|
|
117
|
-
"claude-sonnet-4-6",
|
|
118
|
-
"claude-sonnet-4-6:low",
|
|
119
|
-
"claude-sonnet-4-6:high",
|
|
120
|
-
"claude-codex/gpt-5.5",
|
|
121
|
-
"claude-codex/gpt-5.5:low",
|
|
122
|
-
"claude-codex/gpt-5.5:high",
|
|
123
|
-
"claude-openai/gpt-4o"
|
|
124
|
-
]);
|
|
125
|
-
assert.equal(body.data.find((model) => model.id === "claude-sonnet-4-6:high")?.display_name, "claude-sonnet-4-6 (high)");
|
|
126
|
-
assert.deepEqual(resolveClaudeModelSelection("claude-codex/gpt-5.5:max", ["claude-code/claude-sonnet-4-6", "codex/gpt-5.5", "openai/gpt-4o"], [
|
|
127
|
-
{
|
|
128
|
-
publicId: "codex/gpt-5.5",
|
|
129
|
-
nativeId: "gpt-5.5",
|
|
130
|
-
provider: "codex",
|
|
131
|
-
reasoning
|
|
132
|
-
}
|
|
43
|
+
assert.deepEqual(resolveClaudeModelSelection("anthropic.routekit.codex/gpt-5.5", ["openai/gpt-5.5", "fast", "codex/gpt-5.5"], [
|
|
44
|
+
{ publicId: "openai/gpt-5.5", nativeId: "gpt-5.5", provider: "openai", reasoning },
|
|
45
|
+
{ publicId: "fast", nativeId: "gpt-5.5", provider: "openai", reasoning },
|
|
46
|
+
{ publicId: "codex/gpt-5.5", nativeId: "gpt-5.5", provider: "codex", reasoning }
|
|
133
47
|
]), {
|
|
134
48
|
status: "resolved",
|
|
135
49
|
model: "codex/gpt-5.5",
|
|
136
|
-
clientModel: "
|
|
137
|
-
selection: { mode: "
|
|
50
|
+
clientModel: "anthropic.routekit.codex/gpt-5.5",
|
|
51
|
+
selection: { mode: "auto" }
|
|
138
52
|
});
|
|
53
|
+
assert.deepEqual(resolveClaudeModelSelection("gpt-5.5", ["openai/gpt-5.5", "fast"], [
|
|
54
|
+
{ publicId: "openai/gpt-5.5", nativeId: "gpt-5.5", provider: "openai", reasoning },
|
|
55
|
+
{ publicId: "fast", nativeId: "gpt-5.5", provider: "openai", reasoning }
|
|
56
|
+
]), {
|
|
57
|
+
status: "resolved",
|
|
58
|
+
model: "openai/gpt-5.5",
|
|
59
|
+
clientModel: "gpt-5.5",
|
|
60
|
+
selection: { mode: "auto" }
|
|
61
|
+
});
|
|
62
|
+
const collision = resolveClaudeModelSelection("gpt-5.5", ["openai/gpt-5.5", "codex/gpt-5.5"], [
|
|
63
|
+
{ publicId: "openai/gpt-5.5", nativeId: "gpt-5.5", provider: "openai", reasoning },
|
|
64
|
+
{ publicId: "codex/gpt-5.5", nativeId: "gpt-5.5", provider: "codex", reasoning }
|
|
65
|
+
]);
|
|
66
|
+
assert.equal(collision.status, "ambiguous_model");
|
|
67
|
+
if (collision.status === "ambiguous_model") {
|
|
68
|
+
assert.match(collision.message, /codex\/gpt-5\.5, openai\/gpt-5\.5/);
|
|
69
|
+
}
|
|
70
|
+
// The legacy spelling remains accepted, but is not emitted anywhere.
|
|
71
|
+
assert.equal(resolveClaudeModelAlias("claude-gpt-5.5", ["gpt-5.5"]), "gpt-5.5");
|
|
139
72
|
assert.equal(resolveClaudeModelSelection("claude-codex/gpt-5.5:unknown", ["codex/gpt-5.5"], [
|
|
140
73
|
{
|
|
141
74
|
publicId: "codex/gpt-5.5",
|
|
@@ -735,7 +668,7 @@ test("estimates tokens and serves Anthropic discovery", async () => {
|
|
|
735
668
|
headers: { "anthropic-version": "2023-06-01" }
|
|
736
669
|
});
|
|
737
670
|
const list = (await models.json());
|
|
738
|
-
assert.
|
|
671
|
+
assert.equal(list.data[0]?.id, "local-model");
|
|
739
672
|
}
|
|
740
673
|
finally {
|
|
741
674
|
await gateway.close();
|
|
@@ -781,7 +714,7 @@ test("Claude's implicit thinking default does not reject a base model without re
|
|
|
781
714
|
method: "POST",
|
|
782
715
|
headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" },
|
|
783
716
|
body: JSON.stringify({
|
|
784
|
-
model: "
|
|
717
|
+
model: "anthropic.routekit.openai/mock-model",
|
|
785
718
|
max_tokens: 32,
|
|
786
719
|
thinking: { type: "adaptive" },
|
|
787
720
|
output_config: { effort: "high" },
|
|
@@ -796,7 +729,7 @@ test("Claude's implicit thinking default does not reject a base model without re
|
|
|
796
729
|
await gateway.close();
|
|
797
730
|
}
|
|
798
731
|
});
|
|
799
|
-
test("Claude picker
|
|
732
|
+
test("Claude picker ids and bare native ids use the canonical catalog and pooled native relay", async () => {
|
|
800
733
|
const sourceCalls = [];
|
|
801
734
|
const source = (sourceId) => ({
|
|
802
735
|
sourceId,
|
|
@@ -860,10 +793,11 @@ test("Claude picker aliases use the canonical catalog and pooled native relay",
|
|
|
860
793
|
headers: { "anthropic-version": "2023-06-01" }
|
|
861
794
|
})).json());
|
|
862
795
|
assert.deepEqual(catalog.data.map(({ id, display_name }) => [id, display_name]), [
|
|
863
|
-
["
|
|
864
|
-
["claude-sonnet-4-6", "claude-sonnet-4-6"]
|
|
796
|
+
["codex/gpt-5.5", "codex/gpt-5.5"],
|
|
797
|
+
["claude-code/claude-sonnet-4-6", "claude-code/claude-sonnet-4-6"]
|
|
865
798
|
]);
|
|
866
799
|
for (const model of [
|
|
800
|
+
"anthropic.routekit.claude-code/claude-sonnet-4-6",
|
|
867
801
|
"claude-sonnet-4-6",
|
|
868
802
|
"claude-code/claude-sonnet-4-6"
|
|
869
803
|
]) {
|
|
@@ -885,6 +819,7 @@ test("Claude picker aliases use the canonical catalog and pooled native relay",
|
|
|
885
819
|
.content[0]?.text, "NATIVE_OK");
|
|
886
820
|
}
|
|
887
821
|
assert.deepEqual(relayedBodies.map((body) => body.model), [
|
|
822
|
+
"claude-sonnet-4-6",
|
|
888
823
|
"claude-sonnet-4-6",
|
|
889
824
|
"claude-sonnet-4-6"
|
|
890
825
|
]);
|
|
@@ -905,6 +840,7 @@ test("Claude picker aliases use the canonical catalog and pooled native relay",
|
|
|
905
840
|
assert.equal(unknown.status, 400);
|
|
906
841
|
assert.match(await unknown.text(), /unknown model/);
|
|
907
842
|
assert.deepEqual(relayedBodies.map((body) => body.model), [
|
|
843
|
+
"claude-sonnet-4-6",
|
|
908
844
|
"claude-sonnet-4-6",
|
|
909
845
|
"claude-sonnet-4-6"
|
|
910
846
|
]);
|
|
@@ -913,7 +849,49 @@ test("Claude picker aliases use the canonical catalog and pooled native relay",
|
|
|
913
849
|
await gateway.close();
|
|
914
850
|
}
|
|
915
851
|
});
|
|
916
|
-
test("Claude
|
|
852
|
+
test("Claude rejects an ambiguous bare native model before provider routing", async () => {
|
|
853
|
+
const sourceCalls = [];
|
|
854
|
+
const source = (sourceId) => ({
|
|
855
|
+
sourceId,
|
|
856
|
+
discoverModels: async () => [{ id: "gpt-5.5" }],
|
|
857
|
+
chat: async (body) => {
|
|
858
|
+
sourceCalls.push(body.model);
|
|
859
|
+
return Response.json({});
|
|
860
|
+
},
|
|
861
|
+
embeddings: async () => Response.json({})
|
|
862
|
+
});
|
|
863
|
+
const backend = await CatalogBackend.create({
|
|
864
|
+
config: {
|
|
865
|
+
providers: { openai: {}, codex: {} },
|
|
866
|
+
defaultModel: "openai/gpt-5.5"
|
|
867
|
+
},
|
|
868
|
+
sources: { openai: source("openai"), codex: source("codex") }
|
|
869
|
+
});
|
|
870
|
+
const gateway = await startGateway({ backend });
|
|
871
|
+
try {
|
|
872
|
+
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
873
|
+
method: "POST",
|
|
874
|
+
headers: {
|
|
875
|
+
"content-type": "application/json",
|
|
876
|
+
"anthropic-version": "2023-06-01"
|
|
877
|
+
},
|
|
878
|
+
body: JSON.stringify({
|
|
879
|
+
model: "gpt-5.5",
|
|
880
|
+
max_tokens: 32,
|
|
881
|
+
messages: [{ role: "user", content: "hi" }]
|
|
882
|
+
})
|
|
883
|
+
});
|
|
884
|
+
assert.equal(response.status, 400);
|
|
885
|
+
const payload = (await response.json());
|
|
886
|
+
assert.equal(payload.error.type, "invalid_request_error");
|
|
887
|
+
assert.match(payload.error.message, /codex\/gpt-5\.5, openai\/gpt-5\.5/);
|
|
888
|
+
assert.deepEqual(sourceCalls, []);
|
|
889
|
+
}
|
|
890
|
+
finally {
|
|
891
|
+
await gateway.close();
|
|
892
|
+
}
|
|
893
|
+
});
|
|
894
|
+
test("Claude native effort applies request-scoped effort on picker and bare-native routes", async () => {
|
|
917
895
|
const reasoning = {
|
|
918
896
|
status: "supported",
|
|
919
897
|
efforts: [{ id: "low" }, { id: "high", aliases: ["max"] }],
|
|
@@ -980,9 +958,7 @@ test("Claude effort variants apply request-scoped effort on native and translate
|
|
|
980
958
|
const catalog = (await (await fetch(`${gateway.url()}/v1/models`, {
|
|
981
959
|
headers: { "anthropic-version": "2023-06-01" }
|
|
982
960
|
})).json());
|
|
983
|
-
assert.
|
|
984
|
-
assert.ok(catalog.data.some((model) => model.id === "claude-sonnet-4-6:high"));
|
|
985
|
-
assert.ok(catalog.data.some((model) => model.id === "claude-codex/gpt-5.5:low"));
|
|
961
|
+
assert.deepEqual(catalog.data.map((model) => model.id), ["codex/gpt-5.5", "claude-code/claude-sonnet-4-6"]);
|
|
986
962
|
for (const effort of ["low", "high"]) {
|
|
987
963
|
const response = await fetch(`${gateway.url()}/v1/messages`, {
|
|
988
964
|
method: "POST",
|
|
@@ -991,8 +967,10 @@ test("Claude effort variants apply request-scoped effort on native and translate
|
|
|
991
967
|
"anthropic-version": "2023-06-01"
|
|
992
968
|
},
|
|
993
969
|
body: JSON.stringify({
|
|
994
|
-
model:
|
|
970
|
+
model: "anthropic.routekit.claude-code/claude-sonnet-4-6",
|
|
995
971
|
max_tokens: 32,
|
|
972
|
+
thinking: { type: "adaptive" },
|
|
973
|
+
output_config: { effort },
|
|
996
974
|
messages: [{ role: "user", content: "hi" }]
|
|
997
975
|
})
|
|
998
976
|
});
|
|
@@ -1028,8 +1006,10 @@ test("Claude effort variants apply request-scoped effort on native and translate
|
|
|
1028
1006
|
"anthropic-version": "2023-06-01"
|
|
1029
1007
|
},
|
|
1030
1008
|
body: JSON.stringify({
|
|
1031
|
-
model: "
|
|
1009
|
+
model: "anthropic.routekit.codex/gpt-5.5",
|
|
1032
1010
|
max_tokens: 32,
|
|
1011
|
+
thinking: { type: "adaptive" },
|
|
1012
|
+
output_config: { effort: "high" },
|
|
1033
1013
|
messages: [{ role: "user", content: "hi" }]
|
|
1034
1014
|
})
|
|
1035
1015
|
});
|
|
@@ -1037,25 +1017,31 @@ test("Claude effort variants apply request-scoped effort on native and translate
|
|
|
1037
1017
|
assert.equal(sourceCalls.length, 1);
|
|
1038
1018
|
assert.equal(sourceCalls[0]?.model, "gpt-5.5");
|
|
1039
1019
|
assert.equal(sourceCalls[0]?.reasoning_effort, "high");
|
|
1040
|
-
const
|
|
1020
|
+
const passthrough = await fetch(`${gateway.url()}/v1/messages`, {
|
|
1041
1021
|
method: "POST",
|
|
1042
1022
|
headers: {
|
|
1043
1023
|
"content-type": "application/json",
|
|
1044
1024
|
"anthropic-version": "2023-06-01"
|
|
1045
1025
|
},
|
|
1046
1026
|
body: JSON.stringify({
|
|
1047
|
-
model: "claude-sonnet-4-6
|
|
1027
|
+
model: "anthropic.routekit.claude-code/claude-sonnet-4-6",
|
|
1048
1028
|
max_tokens: 32,
|
|
1029
|
+
thinking: { type: "adaptive" },
|
|
1030
|
+
output_config: { effort: "bogus" },
|
|
1049
1031
|
messages: [{ role: "user", content: "hi" }]
|
|
1050
1032
|
})
|
|
1051
1033
|
});
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1034
|
+
// Claude Code owns the native effort selector. RouteKit must preserve an
|
|
1035
|
+
// unrecognised value for the provider rather than validating it against a
|
|
1036
|
+
// stale discovery catalog or reintroducing synthetic effort model ids.
|
|
1037
|
+
assert.equal(passthrough.status, 200);
|
|
1038
|
+
assert.deepEqual(relayedBodies.at(-1)?.output_config, { effort: "bogus" });
|
|
1039
|
+
assert.deepEqual(relayedBodies.at(-1)?.thinking, { type: "adaptive" });
|
|
1040
|
+
assert.equal(relayedBodies.length, 4);
|
|
1055
1041
|
assert.equal(sourceCalls.length, 1);
|
|
1056
|
-
const retrieve = await fetch(`${gateway.url()}/v1/models/${encodeURIComponent("claude-sonnet-4-6
|
|
1042
|
+
const retrieve = await fetch(`${gateway.url()}/v1/models/${encodeURIComponent("anthropic.routekit.claude-code/claude-sonnet-4-6")}`, { headers: { "anthropic-version": "2023-06-01" } });
|
|
1057
1043
|
assert.equal(retrieve.status, 200);
|
|
1058
|
-
assert.equal((await retrieve.json()).id, "claude-sonnet-4-6
|
|
1044
|
+
assert.equal((await retrieve.json()).id, "anthropic.routekit.claude-code/claude-sonnet-4-6");
|
|
1059
1045
|
}
|
|
1060
1046
|
finally {
|
|
1061
1047
|
await gateway.close();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@velum-labs/routekit-gateway",
|
|
3
3
|
"private": false,
|
|
4
|
-
"version": "0.17.
|
|
4
|
+
"version": "0.17.2",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/velum-labs/routekit.git",
|
|
@@ -29,10 +29,10 @@
|
|
|
29
29
|
"@aws-sdk/client-bedrock": "3.1095.0",
|
|
30
30
|
"@aws-sdk/client-bedrock-runtime": "3.1095.0",
|
|
31
31
|
"zod": "4.4.3",
|
|
32
|
-
"@velum-labs/routekit-contracts": "0.17.
|
|
33
|
-
"@velum-labs/routekit-registry": "0.17.
|
|
34
|
-
"@velum-labs/routekit-runtime": "0.17.
|
|
35
|
-
"@velum-labs/routekit-tracing": "0.17.
|
|
32
|
+
"@velum-labs/routekit-contracts": "0.17.2",
|
|
33
|
+
"@velum-labs/routekit-registry": "0.17.2",
|
|
34
|
+
"@velum-labs/routekit-runtime": "0.17.2",
|
|
35
|
+
"@velum-labs/routekit-tracing": "0.17.2"
|
|
36
36
|
},
|
|
37
37
|
"keywords": [
|
|
38
38
|
"routekit",
|