@velum-labs/routekit-gateway 0.9.5 → 0.9.6

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.
@@ -22,6 +22,24 @@ type JsonObject = Record<string, unknown>;
22
22
  * job; the translation itself stays total.
23
23
  */
24
24
  export declare function isCursorChatBody(body: unknown): body is JsonObject;
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.
32
+ */
33
+ export declare function cursorModelAliasId(id: string): string;
34
+ /**
35
+ * Resolve Cursor's dash-separated spelling back to a served namespaced id.
36
+ *
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.
41
+ */
42
+ export declare function resolveCursorModelAlias(model: unknown, servedIds: readonly string[]): string | undefined;
25
43
  /**
26
44
  * Map a Cursor BYOK request body onto a Chat Completions body.
27
45
  *
@@ -40,6 +40,31 @@ function isObject(value) {
40
40
  export function isCursorChatBody(body) {
41
41
  return isObject(body) && ("messages" in body || "input" in body);
42
42
  }
43
+ /**
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.
50
+ */
51
+ export function cursorModelAliasId(id) {
52
+ return id.replace("/", "-");
53
+ }
54
+ /**
55
+ * Resolve Cursor's dash-separated spelling back to a served namespaced id.
56
+ *
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.
61
+ */
62
+ export function resolveCursorModelAlias(model, servedIds) {
63
+ if (typeof model !== "string" || model.length === 0 || servedIds.includes(model)) {
64
+ return undefined;
65
+ }
66
+ return servedIds.find((id) => id.includes("/") && cursorModelAliasId(id) === model);
67
+ }
43
68
  /**
44
69
  * Map a Cursor BYOK request body onto a Chat Completions body.
45
70
  *
package/dist/server.js CHANGED
@@ -4,7 +4,7 @@ import { ProviderFailureError } 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 { isCursorChatBody, translateCursorRequest } from "./adapters/cursor.js";
7
+ import { cursorModelAliasId, 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,22 @@ 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.
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.
277
279
  if (method === "GET" && path === "/v1/cursor/models") {
278
- await pipeUpstream(res, await backend.models());
280
+ const upstream = await backend.models();
281
+ if (!upstream.ok) {
282
+ await pipeUpstream(res, upstream);
283
+ return;
284
+ }
285
+ const payload = (await upstream.json());
286
+ writeJson(res, 200, {
287
+ ...payload,
288
+ data: (payload.data ?? []).map((entry) => typeof entry.id === "string"
289
+ ? { ...entry, id: cursorModelAliasId(entry.id) }
290
+ : entry)
291
+ });
279
292
  return;
280
293
  }
281
294
  // Anthropic single-model retrieve (`GET /v1/models/{id}`): Claude Code probes
@@ -349,6 +362,9 @@ export async function startGateway(options) {
349
362
  const translated = translateCursorRequest(raw);
350
363
  if (rejectInvalid(res, validateChatRequest(translated)))
351
364
  return;
365
+ const aliased = resolveCursorModelAlias(translated.model, backend.listModelIds?.() ?? []);
366
+ if (aliased !== undefined)
367
+ translated.model = aliased;
352
368
  const body = withDefaultModel(translated, backend.defaultModel);
353
369
  await handleModelCall(res, provenance, {
354
370
  dialect: "openai-chat",
@@ -1,6 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { test } from "node:test";
3
- import { isCursorChatBody, translateCursorRequest } from "../adapters/cursor.js";
3
+ import { cursorModelAliasId, isCursorChatBody, resolveCursorModelAlias, translateCursorRequest } from "../adapters/cursor.js";
4
4
  import { startGateway } from "../server.js";
5
5
  const cursorBody = {
6
6
  model: "route-primary",
@@ -47,6 +47,18 @@ 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", () => {
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
+ const served = ["claude-code/claude-fable-5", "openai/gpt-4o", "route-primary"];
55
+ assert.equal(resolveCursorModelAlias("claude-code-claude-fable-5", served), "claude-code/claude-fable-5");
56
+ assert.equal(resolveCursorModelAlias("openai-gpt-4o", served), "openai/gpt-4o");
57
+ // Served-as-spelled ids and unknown names never rewrite.
58
+ assert.equal(resolveCursorModelAlias("route-primary", served), undefined);
59
+ assert.equal(resolveCursorModelAlias("claude-fable-5", served), undefined);
60
+ assert.equal(resolveCursorModelAlias(undefined, served), undefined);
61
+ });
50
62
  test("Cursor hybrid detection rejects unrelated bodies", () => {
51
63
  assert.equal(isCursorChatBody({ input: "hello" }), true);
52
64
  assert.equal(isCursorChatBody({ messages: [] }), true);
@@ -98,3 +110,53 @@ test("RouteKit serves the Cursor hybrid through its neutral HTTP boundary", asyn
98
110
  await gateway.close();
99
111
  }
100
112
  });
113
+ test("Cursor route resolves dashed model aliases to namespaced ids", async () => {
114
+ let received;
115
+ const backend = {
116
+ defaultModel: "claude-code/claude-fable-5",
117
+ chat(body) {
118
+ received = body;
119
+ return Promise.resolve(Response.json({
120
+ id: "chatcmpl_2",
121
+ object: "chat.completion",
122
+ model: "claude-code/claude-fable-5",
123
+ choices: [
124
+ {
125
+ index: 0,
126
+ message: { role: "assistant", content: "done" },
127
+ finish_reason: "stop"
128
+ }
129
+ ]
130
+ }));
131
+ },
132
+ models: () => Promise.resolve(Response.json({
133
+ object: "list",
134
+ data: [
135
+ { id: "claude-code/claude-fable-5", object: "model" },
136
+ { id: "openai/gpt-4o", object: "model" }
137
+ ]
138
+ })),
139
+ listModelIds: () => ["claude-code/claude-fable-5", "openai/gpt-4o"],
140
+ embeddings: () => Promise.resolve(new Response(null, { status: 501 }))
141
+ };
142
+ const gateway = await startGateway({ backend });
143
+ try {
144
+ const response = await fetch(`${gateway.url()}/v1/cursor/chat/completions`, {
145
+ method: "POST",
146
+ headers: { "content-type": "application/json" },
147
+ body: JSON.stringify({
148
+ model: "claude-code-claude-fable-5",
149
+ messages: [{ role: "user", content: "hi" }]
150
+ })
151
+ });
152
+ assert.equal(response.status, 200);
153
+ assert.equal(received?.model, "claude-code/claude-fable-5");
154
+ // The models mirror advertises the dashed spelling Cursor accepts.
155
+ const models = await fetch(`${gateway.url()}/v1/cursor/models`);
156
+ assert.equal(models.status, 200);
157
+ assert.deepEqual((await models.json()).data.map((model) => model.id), ["claude-code-claude-fable-5", "openai-gpt-4o"]);
158
+ }
159
+ finally {
160
+ await gateway.close();
161
+ }
162
+ });
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.5",
4
+ "version": "0.9.6",
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.5",
31
- "@velum-labs/routekit-tracing": "0.9.5",
32
- "@velum-labs/routekit-runtime": "0.9.5",
33
- "@velum-labs/routekit-registry": "0.9.5"
30
+ "@velum-labs/routekit-contracts": "0.9.6",
31
+ "@velum-labs/routekit-registry": "0.9.6",
32
+ "@velum-labs/routekit-runtime": "0.9.6",
33
+ "@velum-labs/routekit-tracing": "0.9.6"
34
34
  },
35
35
  "keywords": [
36
36
  "routekit",