@velum-labs/routekit-gateway 0.9.7 → 0.9.9
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/cursor.d.ts +11 -11
- package/dist/adapters/cursor.js +16 -11
- package/dist/router.js +1 -1
- package/dist/server.js +7 -6
- package/dist/test/cursor.test.js +42 -16
- package/package.json +5 -5
|
@@ -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
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
36
|
+
* Resolve a Cursor-facing model name back to a served id.
|
|
36
37
|
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
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
|
/**
|
package/dist/adapters/cursor.js
CHANGED
|
@@ -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
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
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
|
|
57
|
+
* Resolve a Cursor-facing model name back to a served id.
|
|
56
58
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
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 "/";
|
|
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 {
|
|
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.
|
|
277
|
-
//
|
|
278
|
-
// the
|
|
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:
|
|
290
|
+
? { ...entry, id: cursorModelName(entry.id) }
|
|
290
291
|
: entry)
|
|
291
292
|
});
|
|
292
293
|
return;
|
package/dist/test/cursor.test.js
CHANGED
|
@@ -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
|
|
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("
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
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: () => [
|
|
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
|
|
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(
|
|
173
|
+
assert.equal(legacy.status, 200);
|
|
158
174
|
assert.equal(received?.model, "claude-code/claude-fable-5");
|
|
159
|
-
// The models mirror advertises
|
|
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
|
-
|
|
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.
|
|
4
|
+
"version": "0.9.9",
|
|
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.
|
|
31
|
-
"@velum-labs/routekit-
|
|
32
|
-
"@velum-labs/routekit-runtime": "0.9.
|
|
33
|
-
"@velum-labs/routekit-
|
|
30
|
+
"@velum-labs/routekit-contracts": "0.9.9",
|
|
31
|
+
"@velum-labs/routekit-registry": "0.9.9",
|
|
32
|
+
"@velum-labs/routekit-runtime": "0.9.9",
|
|
33
|
+
"@velum-labs/routekit-tracing": "0.9.9"
|
|
34
34
|
},
|
|
35
35
|
"keywords": [
|
|
36
36
|
"routekit",
|