@velum-labs/routekit-gateway 0.9.7 → 0.9.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.
@@ -23,21 +23,21 @@ type JsonObject = Record<string, unknown>;
23
23
  */
24
24
  export declare function isCursorChatBody(body: unknown): body is JsonObject;
25
25
  /**
26
- * Spell a namespaced model id the way Cursor's custom-model settings accept
27
- * it. Cursor rejects ids containing "/" ("Model name is not valid"), so the
28
- * cursor route advertises and answers to a dash-separated spelling
29
- * (`claude-code/claude-fable-5` -> `claude-code-claude-fable-5`). Dashes are
30
- * preferred over dots because dotted names can collide with Cursor's managed
31
- * model catalog.
26
+ * Spell a namespaced model id with dashes. Kept for one-release back-compat
27
+ * with clients that still send the 0.9.6 `/v1/cursor` spelling
28
+ * (`claude-code/claude-fable-5` → `claude-code-claude-fable-5`). New
29
+ * advertising uses `cursorModelName` from `@velum-labs/routekit-contracts`
30
+ * (`routekit/<id>`) so no advertised name starts with `claude-` or
31
+ * `gemini-`, which Cursor routes to the Anthropic/Google keys instead of
32
+ * the OpenAI base-URL override. Deprecated: prefer `cursorModelName`.
32
33
  */
33
34
  export declare function cursorModelAliasId(id: string): string;
34
35
  /**
35
- * Resolve Cursor's dash-separated spelling back to a served namespaced id.
36
+ * Resolve a Cursor-facing model name back to a served id.
36
37
  *
37
- * Resolution is an exact lookup over the gateway's served ids rather than a
38
- * separator split, because namespaces themselves contain dashes. A rewrite
39
- * only happens when the model is not served as spelled; if two served ids
40
- * produce the same alias, the first one listed wins.
38
+ * Order: served as spelled (no rewrite) → strip `routekit/` → legacy
39
+ * 0.9.6 dashed spelling. Returns `undefined` when the name is already a
40
+ * served id or cannot be resolved.
41
41
  */
42
42
  export declare function resolveCursorModelAlias(model: unknown, servedIds: readonly string[]): string | undefined;
43
43
  /**
@@ -14,6 +14,7 @@
14
14
  * never a reason to throw — unknown item and tool types are dropped so the
15
15
  * boundary stays defensive without 4xx-ing on new shapes.
16
16
  */
17
+ import { stripCursorNamespace } from "@velum-labs/routekit-contracts";
17
18
  import { droppedField } from "./dropped.js";
18
19
  import { attachReasoningSelection, attachReasoningSelectionError } from "./openai-chat-wire.js";
19
20
  /** Fields copied through unchanged when present and non-null. */
@@ -41,28 +42,32 @@ export function isCursorChatBody(body) {
41
42
  return isObject(body) && ("messages" in body || "input" in body);
42
43
  }
43
44
  /**
44
- * Spell a namespaced model id the way Cursor's custom-model settings accept
45
- * it. Cursor rejects ids containing "/" ("Model name is not valid"), so the
46
- * cursor route advertises and answers to a dash-separated spelling
47
- * (`claude-code/claude-fable-5` -> `claude-code-claude-fable-5`). Dashes are
48
- * preferred over dots because dotted names can collide with Cursor's managed
49
- * model catalog.
45
+ * Spell a namespaced model id with dashes. Kept for one-release back-compat
46
+ * with clients that still send the 0.9.6 `/v1/cursor` spelling
47
+ * (`claude-code/claude-fable-5` → `claude-code-claude-fable-5`). New
48
+ * advertising uses `cursorModelName` from `@velum-labs/routekit-contracts`
49
+ * (`routekit/<id>`) so no advertised name starts with `claude-` or
50
+ * `gemini-`, which Cursor routes to the Anthropic/Google keys instead of
51
+ * the OpenAI base-URL override. Deprecated: prefer `cursorModelName`.
50
52
  */
51
53
  export function cursorModelAliasId(id) {
52
54
  return id.replaceAll("/", "-");
53
55
  }
54
56
  /**
55
- * Resolve Cursor's dash-separated spelling back to a served namespaced id.
57
+ * Resolve a Cursor-facing model name back to a served id.
56
58
  *
57
- * Resolution is an exact lookup over the gateway's served ids rather than a
58
- * separator split, because namespaces themselves contain dashes. A rewrite
59
- * only happens when the model is not served as spelled; if two served ids
60
- * produce the same alias, the first one listed wins.
59
+ * Order: served as spelled (no rewrite) → strip `routekit/` → legacy
60
+ * 0.9.6 dashed spelling. Returns `undefined` when the name is already a
61
+ * served id or cannot be resolved.
61
62
  */
62
63
  export function resolveCursorModelAlias(model, servedIds) {
63
64
  if (typeof model !== "string" || model.length === 0 || servedIds.includes(model)) {
64
65
  return undefined;
65
66
  }
67
+ const stripped = stripCursorNamespace(model);
68
+ if (stripped !== undefined && servedIds.includes(stripped)) {
69
+ return stripped;
70
+ }
66
71
  return servedIds.find((id) => id.includes("/") && cursorModelAliasId(id) === model);
67
72
  }
68
73
  /**
package/dist/router.js CHANGED
@@ -146,7 +146,7 @@ export function parseRouterConfig(value) {
146
146
  }
147
147
  for (const [alias, target] of Object.entries(config.modelAliases ?? {})) {
148
148
  if (alias.includes("/")) {
149
- throw new Error(`model alias "${alias}" must not contain "/"; aliases exist to give namespaced models slash-free names`);
149
+ throw new Error(`model alias "${alias}" must not contain "/"; alias keys must stay distinct from namespaced model ids`);
150
150
  }
151
151
  const selected = splitNamespacedModel(target);
152
152
  if (config.providers[selected.provider] === undefined) {
package/dist/server.js CHANGED
@@ -1,10 +1,10 @@
1
1
  import { once } from "node:events";
2
2
  import { createServer } from "node:http";
3
- import { ProviderFailureError } from "@velum-labs/routekit-contracts";
3
+ import { ProviderFailureError, cursorModelName } from "@velum-labs/routekit-contracts";
4
4
  import { anthropicModelsResponse, handleAnthropicMessages, handleCountTokens, resolveClaudeModelAlias } from "./adapters/anthropic.js";
5
5
  import { effectiveModel, isStream, withDefaultModel } from "./adapters/chat.js";
6
6
  import { authorizedRequest } from "./auth.js";
7
- import { cursorModelAliasId, isCursorChatBody, resolveCursorModelAlias, translateCursorRequest } from "./adapters/cursor.js";
7
+ import { isCursorChatBody, resolveCursorModelAlias, translateCursorRequest } from "./adapters/cursor.js";
8
8
  import { handleResponses } from "./adapters/responses.js";
9
9
  import { validateAnthropicRequest, validateChatRequest, validateCountTokensRequest, validateResponsesRequest } from "./adapters/validate.js";
10
10
  import { buildModelCallRecord, MODEL_CALL_ID_HEADER, modelCallId } from "./provenance.js";
@@ -273,9 +273,10 @@ export async function startGateway(options) {
273
273
  return;
274
274
  }
275
275
  // Cursor may probe the models list relative to its BYOK base URL
276
- // (`.../v1/cursor`); mirror /v1/models there. Namespaced ids are respelled
277
- // with dashes because Cursor's custom-model settings reject "/" in names;
278
- // the chat route below resolves the dashed spelling back.
276
+ // (`.../v1/cursor`); mirror /v1/models there. Every id is namespaced under
277
+ // `routekit/` so no advertised name starts with `claude-` or `gemini-`,
278
+ // which Cursor routes to the Anthropic/Google keys instead of the OpenAI
279
+ // base-URL override. The chat route below strips the namespace back.
279
280
  if (method === "GET" && path === "/v1/cursor/models") {
280
281
  const upstream = await backend.models();
281
282
  if (!upstream.ok) {
@@ -286,7 +287,7 @@ export async function startGateway(options) {
286
287
  writeJson(res, 200, {
287
288
  ...payload,
288
289
  data: (payload.data ?? []).map((entry) => typeof entry.id === "string"
289
- ? { ...entry, id: cursorModelAliasId(entry.id) }
290
+ ? { ...entry, id: cursorModelName(entry.id) }
290
291
  : entry)
291
292
  });
292
293
  return;
@@ -47,21 +47,21 @@ test("Cursor hybrid requests translate to chat messages and tools", () => {
47
47
  ]);
48
48
  assert.equal(translated.tools[0]?.function.name, "read_file");
49
49
  });
50
- test("Cursor model aliases respell namespaced ids with dashes", () => {
50
+ test("Cursor model names namespace under routekit/ and resolve back", () => {
51
51
  assert.equal(cursorModelAliasId("claude-code/claude-fable-5"), "claude-code-claude-fable-5");
52
- assert.equal(cursorModelAliasId("openai/gpt-4o"), "openai-gpt-4o");
53
- assert.equal(cursorModelAliasId("route-primary"), "route-primary");
54
- assert.equal(cursorModelAliasId("openrouter/moonshotai/kimi-k2-thinking"), "openrouter-moonshotai-kimi-k2-thinking", "every slash is respelled, not just the namespace separator");
52
+ assert.equal(cursorModelAliasId("openrouter/moonshotai/kimi-k2-thinking"), "openrouter-moonshotai-kimi-k2-thinking", "legacy dashed spelling still respells every slash");
55
53
  const served = ["claude-code/claude-fable-5", "openai/gpt-4o", "route-primary"];
56
- assert.equal(resolveCursorModelAlias("openrouter-moonshotai-kimi-k2-thinking", [
57
- ...served,
58
- "openrouter/moonshotai/kimi-k2-thinking"
59
- ]), "openrouter/moonshotai/kimi-k2-thinking");
54
+ // Namespaced Cursor-facing spelling.
55
+ assert.equal(resolveCursorModelAlias("routekit/claude-code/claude-fable-5", served), "claude-code/claude-fable-5");
56
+ assert.equal(resolveCursorModelAlias("routekit/openai/gpt-4o", served), "openai/gpt-4o");
57
+ assert.equal(resolveCursorModelAlias("routekit/route-primary", served), "route-primary");
58
+ // Legacy 0.9.6 dashed spelling still resolves.
60
59
  assert.equal(resolveCursorModelAlias("claude-code-claude-fable-5", served), "claude-code/claude-fable-5");
61
60
  assert.equal(resolveCursorModelAlias("openai-gpt-4o", served), "openai/gpt-4o");
62
61
  // Served-as-spelled ids and unknown names never rewrite.
63
62
  assert.equal(resolveCursorModelAlias("route-primary", served), undefined);
64
63
  assert.equal(resolveCursorModelAlias("claude-fable-5", served), undefined);
64
+ assert.equal(resolveCursorModelAlias("routekit/", served), undefined);
65
65
  assert.equal(resolveCursorModelAlias(undefined, served), undefined);
66
66
  });
67
67
  test("Cursor hybrid detection rejects unrelated bodies", () => {
@@ -109,13 +109,13 @@ test("RouteKit serves the Cursor hybrid through its neutral HTTP boundary", asyn
109
109
  });
110
110
  const models = await fetch(`${gateway.url()}/v1/cursor/models`);
111
111
  assert.equal(models.status, 200);
112
- assert.deepEqual((await models.json()).data.map((model) => model.id), ["route-primary"]);
112
+ assert.deepEqual((await models.json()).data.map((model) => model.id), ["routekit/route-primary"]);
113
113
  }
114
114
  finally {
115
115
  await gateway.close();
116
116
  }
117
117
  });
118
- test("Cursor route resolves dashed model aliases to namespaced ids", async () => {
118
+ test("Cursor route namespaces advertised ids and resolves them on ingress", async () => {
119
119
  let received;
120
120
  const backend = {
121
121
  defaultModel: "claude-code/claude-fable-5",
@@ -138,15 +138,31 @@ test("Cursor route resolves dashed model aliases to namespaced ids", async () =>
138
138
  object: "list",
139
139
  data: [
140
140
  { id: "claude-code/claude-fable-5", object: "model" },
141
- { id: "openai/gpt-4o", object: "model" }
141
+ { id: "openai/gpt-4o", object: "model" },
142
+ { id: "gemini-proxy/gemini-zzz-9", object: "model" }
142
143
  ]
143
144
  })),
144
- listModelIds: () => ["claude-code/claude-fable-5", "openai/gpt-4o"],
145
+ listModelIds: () => [
146
+ "claude-code/claude-fable-5",
147
+ "openai/gpt-4o",
148
+ "gemini-proxy/gemini-zzz-9"
149
+ ],
145
150
  embeddings: () => Promise.resolve(new Response(null, { status: 501 }))
146
151
  };
147
152
  const gateway = await startGateway({ backend });
148
153
  try {
149
- const response = await fetch(`${gateway.url()}/v1/cursor/chat/completions`, {
154
+ const namespaced = await fetch(`${gateway.url()}/v1/cursor/chat/completions`, {
155
+ method: "POST",
156
+ headers: { "content-type": "application/json" },
157
+ body: JSON.stringify({
158
+ model: "routekit/claude-code/claude-fable-5",
159
+ messages: [{ role: "user", content: "hi" }]
160
+ })
161
+ });
162
+ assert.equal(namespaced.status, 200);
163
+ assert.equal(received?.model, "claude-code/claude-fable-5");
164
+ // Legacy dashed spelling still resolves for one-release back-compat.
165
+ const legacy = await fetch(`${gateway.url()}/v1/cursor/chat/completions`, {
150
166
  method: "POST",
151
167
  headers: { "content-type": "application/json" },
152
168
  body: JSON.stringify({
@@ -154,12 +170,22 @@ test("Cursor route resolves dashed model aliases to namespaced ids", async () =>
154
170
  messages: [{ role: "user", content: "hi" }]
155
171
  })
156
172
  });
157
- assert.equal(response.status, 200);
173
+ assert.equal(legacy.status, 200);
158
174
  assert.equal(received?.model, "claude-code/claude-fable-5");
159
- // The models mirror advertises the dashed spelling Cursor accepts.
175
+ // The models mirror advertises routekit/-namespaced ids that never start
176
+ // with claude- or gemini- (Cursor's BYOK provider-selection prefixes).
160
177
  const models = await fetch(`${gateway.url()}/v1/cursor/models`);
161
178
  assert.equal(models.status, 200);
162
- assert.deepEqual((await models.json()).data.map((model) => model.id), ["claude-code-claude-fable-5", "openai-gpt-4o"]);
179
+ const ids = (await models.json()).data.map((model) => model.id);
180
+ assert.deepEqual(ids, [
181
+ "routekit/claude-code/claude-fable-5",
182
+ "routekit/openai/gpt-4o",
183
+ "routekit/gemini-proxy/gemini-zzz-9"
184
+ ]);
185
+ for (const id of ids) {
186
+ assert.equal(id.startsWith("claude-"), false, id);
187
+ assert.equal(id.startsWith("gemini-"), false, id);
188
+ }
163
189
  }
164
190
  finally {
165
191
  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.9.7",
4
+ "version": "0.9.8",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/velum-labs/handoffkit.git",
@@ -27,10 +27,10 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "zod": "4.4.3",
30
- "@velum-labs/routekit-contracts": "0.9.7",
31
- "@velum-labs/routekit-tracing": "0.9.7",
32
- "@velum-labs/routekit-runtime": "0.9.7",
33
- "@velum-labs/routekit-registry": "0.9.7"
30
+ "@velum-labs/routekit-registry": "0.9.8",
31
+ "@velum-labs/routekit-tracing": "0.9.8",
32
+ "@velum-labs/routekit-runtime": "0.9.8",
33
+ "@velum-labs/routekit-contracts": "0.9.8"
34
34
  },
35
35
  "keywords": [
36
36
  "routekit",