@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.
@@ -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
- /** The `claude-` prefix used to alias non-Anthropic models past Claude's filter. */
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
- /** Qualify a Claude picker base id with a launch-time effort selection. */
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` discovery response. Every advertised model is
157
- * listed so it appears in Claude Code's `/model` picker: Anthropic-family ids
158
- * as-is, others under a `claude-`prefixed alias with the real id as
159
- * `display_name`. Reasoning-capable models also advertise one
160
- * `<base>:<effort>` entry per discovered effort. `modelIds` is the full
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[], modelRoutes?: readonly ClaudePickerModelRoute[]): Response;
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, effortQualifiedClientModel, enumerateModelEffortVariants, resolveModelEffortVariant } from "@velum-labs/routekit-contracts";
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 only lists models whose id begins with `claude` or `anthropic`. */
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
- * The id a model is advertised under in Claude Code's `/model` picker. Claude
1131
- * only lists ids beginning with `claude`/`anthropic`, so non-Anthropic models
1132
- * are aliased with a `claude-` prefix; the gateway maps the alias back when
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 isAnthropicFamilyId(id) ? id : `${CLAUDE_ALIAS_PREFIX}${id}`;
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 claudeVariantEntries(modelIds, modelRoutes) {
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: claudePickerClientModel(route),
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" ? undefined : selection.model;
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: route === undefined ? claudeModelAlias(requested) : claudePickerClientModel(route),
1201
+ clientModel: requested,
1180
1202
  selection: { mode: "auto" }
1181
1203
  };
1182
1204
  }
1183
- const resolved = resolveModelEffortVariant(requested, claudeVariantEntries(modelIds, modelRoutes), EFFORT_QUALIFIED_MODEL_CODEC);
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
- /** Qualify a Claude picker base id with a launch-time effort selection. */
1286
+ /** Historical effort-qualified spelling retained for compatibility exports. */
1235
1287
  export function claudeEffortQualifiedModel(baseClientModel, selection) {
1236
- return effortQualifiedClientModel(baseClientModel, selection, EFFORT_QUALIFIED_MODEL_CODEC);
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` discovery response. Every advertised model is
1240
- * listed so it appears in Claude Code's `/model` picker: Anthropic-family ids
1241
- * as-is, others under a `claude-`prefixed alias with the real id as
1242
- * `display_name`. Reasoning-capable models also advertise one
1243
- * `<base>:<effort>` entry per discovered effort. `modelIds` is the full
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, modelRoutes = []) {
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
- const route = routes.get(realId) ?? {
1258
- publicId: realId,
1259
- nativeId: realId,
1260
- provider: "unknown"
1261
- };
1262
- const displayName = route.provider === "claude-code" ? route.nativeId : realId;
1263
- for (const variant of enumerateModelEffortVariants({
1264
- model: realId,
1265
- clientModel: claudePickerClientModel(route),
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, claudeModelAlias, mapStopReason, openAiSseToAnthropic, resolveClaudeModelAlias, resolveClaudeModelSelection, withClaudeReasoningSelection } from "../adapters/anthropic.js";
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 aliases every model past Claude Code's claude/anthropic filter", async () => {
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
- "claude-route-primary",
24
+ "route-primary",
28
25
  "claude-opus-4-8",
29
- "claude-gpt-5.5",
30
- "claude-mlx-community/Qwen3-1.7B-4bit"
26
+ "gpt-5.5",
27
+ "mlx-community/Qwen3-1.7B-4bit"
31
28
  ]);
32
- // The picker shows the real id via display_name.
33
- const gpt = body.data.find((model) => model.id === "claude-gpt-5.5");
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("anthropicModelsResponse emits base plus discovered effort variants", async () => {
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
- const response = anthropicModelsResponse("claude-code/claude-sonnet-4-6", ["claude-code/claude-sonnet-4-6", "codex/gpt-5.5", "openai/gpt-4o"], [
97
- {
98
- publicId: "claude-code/claude-sonnet-4-6",
99
- nativeId: "claude-sonnet-4-6",
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: "claude-codex/gpt-5.5",
137
- selection: { mode: "effort", effort: "high" }
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.ok(list.data[0]?.id.startsWith("claude"));
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: "claude-openai/mock-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 aliases use the canonical catalog and pooled native relay", async () => {
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
- ["claude-codex/gpt-5.5", "codex/gpt-5.5"],
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 effort variants apply request-scoped effort on native and translated routes", async () => {
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.ok(catalog.data.some((model) => model.id === "claude-sonnet-4-6"));
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: `claude-sonnet-4-6:${effort}`,
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: "claude-codex/gpt-5.5:max",
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 rejected = await fetch(`${gateway.url()}/v1/messages`, {
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:bogus",
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
- assert.equal(rejected.status, 400);
1053
- assert.match(await rejected.text(), /not supported/);
1054
- assert.equal(relayedBodies.length, 3);
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:high")}`, { headers: { "anthropic-version": "2023-06-01" } });
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:high");
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.0",
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.0",
33
- "@velum-labs/routekit-registry": "0.17.0",
34
- "@velum-labs/routekit-runtime": "0.17.0",
35
- "@velum-labs/routekit-tracing": "0.17.0"
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",