@velum-labs/routekit-gateway 0.9.5 → 0.9.7
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 +18 -0
- package/dist/adapters/cursor.js +25 -0
- package/dist/router.d.ts +1 -0
- package/dist/router.js +20 -0
- package/dist/server.js +19 -3
- package/dist/test/cursor.test.js +68 -1
- package/dist/test/router.test.js +52 -0
- package/package.json +5 -5
|
@@ -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
|
*
|
package/dist/adapters/cursor.js
CHANGED
|
@@ -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.replaceAll("/", "-");
|
|
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/router.d.ts
CHANGED
|
@@ -94,6 +94,7 @@ export declare const routerConfigSchema: z.ZodObject<{
|
|
|
94
94
|
}, z.core.$strict>>;
|
|
95
95
|
}, z.core.$strict>;
|
|
96
96
|
defaultModel: z.ZodOptional<z.ZodString>;
|
|
97
|
+
modelAliases: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
|
|
97
98
|
reasoningCapabilities: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
98
99
|
status: z.ZodDefault<z.ZodEnum<{
|
|
99
100
|
unknown: "unknown";
|
package/dist/router.js
CHANGED
|
@@ -96,6 +96,7 @@ export const routerConfigSchema = z
|
|
|
96
96
|
})
|
|
97
97
|
.strict(),
|
|
98
98
|
defaultModel: z.string().min(3).optional(),
|
|
99
|
+
modelAliases: z.record(z.string().min(1), z.string().min(3)).optional(),
|
|
99
100
|
reasoningCapabilities: z
|
|
100
101
|
.record(z.string().min(3), reasoningCapabilityOverrideSchema)
|
|
101
102
|
.optional()
|
|
@@ -143,6 +144,15 @@ export function parseRouterConfig(value) {
|
|
|
143
144
|
throw new Error(`default model provider "${selected.provider}" is not configured`);
|
|
144
145
|
}
|
|
145
146
|
}
|
|
147
|
+
for (const [alias, target] of Object.entries(config.modelAliases ?? {})) {
|
|
148
|
+
if (alias.includes("/")) {
|
|
149
|
+
throw new Error(`model alias "${alias}" must not contain "/"; aliases exist to give namespaced models slash-free names`);
|
|
150
|
+
}
|
|
151
|
+
const selected = splitNamespacedModel(target);
|
|
152
|
+
if (config.providers[selected.provider] === undefined) {
|
|
153
|
+
throw new Error(`model alias "${alias}" targets "${target}" but provider "${selected.provider}" is not configured`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
146
156
|
return config;
|
|
147
157
|
}
|
|
148
158
|
function routeBilling(provider) {
|
|
@@ -236,6 +246,16 @@ export class CatalogBackend {
|
|
|
236
246
|
});
|
|
237
247
|
}
|
|
238
248
|
}
|
|
249
|
+
for (const [alias, target] of Object.entries(config.modelAliases ?? {})) {
|
|
250
|
+
const entry = entries.get(target);
|
|
251
|
+
if (entry === undefined) {
|
|
252
|
+
throw new Error(`model alias "${alias}" targets "${target}", which no configured provider serves`);
|
|
253
|
+
}
|
|
254
|
+
if (entries.has(alias)) {
|
|
255
|
+
throw new Error(`model alias "${alias}" collides with a served model id`);
|
|
256
|
+
}
|
|
257
|
+
entries.set(alias, { ...entry, publicId: alias });
|
|
258
|
+
}
|
|
239
259
|
const first = entries.keys().next().value;
|
|
240
260
|
const defaultModel = config.defaultModel ?? first;
|
|
241
261
|
if (defaultModel === undefined) {
|
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
|
-
|
|
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",
|
package/dist/test/cursor.test.js
CHANGED
|
@@ -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,23 @@ 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
|
+
assert.equal(cursorModelAliasId("openrouter/moonshotai/kimi-k2-thinking"), "openrouter-moonshotai-kimi-k2-thinking", "every slash is respelled, not just the namespace separator");
|
|
55
|
+
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");
|
|
60
|
+
assert.equal(resolveCursorModelAlias("claude-code-claude-fable-5", served), "claude-code/claude-fable-5");
|
|
61
|
+
assert.equal(resolveCursorModelAlias("openai-gpt-4o", served), "openai/gpt-4o");
|
|
62
|
+
// Served-as-spelled ids and unknown names never rewrite.
|
|
63
|
+
assert.equal(resolveCursorModelAlias("route-primary", served), undefined);
|
|
64
|
+
assert.equal(resolveCursorModelAlias("claude-fable-5", served), undefined);
|
|
65
|
+
assert.equal(resolveCursorModelAlias(undefined, served), undefined);
|
|
66
|
+
});
|
|
50
67
|
test("Cursor hybrid detection rejects unrelated bodies", () => {
|
|
51
68
|
assert.equal(isCursorChatBody({ input: "hello" }), true);
|
|
52
69
|
assert.equal(isCursorChatBody({ messages: [] }), true);
|
|
@@ -98,3 +115,53 @@ test("RouteKit serves the Cursor hybrid through its neutral HTTP boundary", asyn
|
|
|
98
115
|
await gateway.close();
|
|
99
116
|
}
|
|
100
117
|
});
|
|
118
|
+
test("Cursor route resolves dashed model aliases to namespaced ids", async () => {
|
|
119
|
+
let received;
|
|
120
|
+
const backend = {
|
|
121
|
+
defaultModel: "claude-code/claude-fable-5",
|
|
122
|
+
chat(body) {
|
|
123
|
+
received = body;
|
|
124
|
+
return Promise.resolve(Response.json({
|
|
125
|
+
id: "chatcmpl_2",
|
|
126
|
+
object: "chat.completion",
|
|
127
|
+
model: "claude-code/claude-fable-5",
|
|
128
|
+
choices: [
|
|
129
|
+
{
|
|
130
|
+
index: 0,
|
|
131
|
+
message: { role: "assistant", content: "done" },
|
|
132
|
+
finish_reason: "stop"
|
|
133
|
+
}
|
|
134
|
+
]
|
|
135
|
+
}));
|
|
136
|
+
},
|
|
137
|
+
models: () => Promise.resolve(Response.json({
|
|
138
|
+
object: "list",
|
|
139
|
+
data: [
|
|
140
|
+
{ id: "claude-code/claude-fable-5", object: "model" },
|
|
141
|
+
{ id: "openai/gpt-4o", object: "model" }
|
|
142
|
+
]
|
|
143
|
+
})),
|
|
144
|
+
listModelIds: () => ["claude-code/claude-fable-5", "openai/gpt-4o"],
|
|
145
|
+
embeddings: () => Promise.resolve(new Response(null, { status: 501 }))
|
|
146
|
+
};
|
|
147
|
+
const gateway = await startGateway({ backend });
|
|
148
|
+
try {
|
|
149
|
+
const response = await fetch(`${gateway.url()}/v1/cursor/chat/completions`, {
|
|
150
|
+
method: "POST",
|
|
151
|
+
headers: { "content-type": "application/json" },
|
|
152
|
+
body: JSON.stringify({
|
|
153
|
+
model: "claude-code-claude-fable-5",
|
|
154
|
+
messages: [{ role: "user", content: "hi" }]
|
|
155
|
+
})
|
|
156
|
+
});
|
|
157
|
+
assert.equal(response.status, 200);
|
|
158
|
+
assert.equal(received?.model, "claude-code/claude-fable-5");
|
|
159
|
+
// The models mirror advertises the dashed spelling Cursor accepts.
|
|
160
|
+
const models = await fetch(`${gateway.url()}/v1/cursor/models`);
|
|
161
|
+
assert.equal(models.status, 200);
|
|
162
|
+
assert.deepEqual((await models.json()).data.map((model) => model.id), ["claude-code-claude-fable-5", "openai-gpt-4o"]);
|
|
163
|
+
}
|
|
164
|
+
finally {
|
|
165
|
+
await gateway.close();
|
|
166
|
+
}
|
|
167
|
+
});
|
package/dist/test/router.test.js
CHANGED
|
@@ -156,6 +156,58 @@ test("catalog namespaces live models and strips the source before dispatch", asy
|
|
|
156
156
|
});
|
|
157
157
|
assert.equal(backend.modelInfo("openai/not-real"), undefined);
|
|
158
158
|
});
|
|
159
|
+
test("configured model aliases serve namespaced models under slash-free names", async () => {
|
|
160
|
+
const calls = [];
|
|
161
|
+
const backend = await CatalogBackend.create({
|
|
162
|
+
config: {
|
|
163
|
+
providers: { "claude-code": {} },
|
|
164
|
+
modelAliases: { "velum-fable-5": "claude-code/claude-fable-5" }
|
|
165
|
+
},
|
|
166
|
+
sources: {
|
|
167
|
+
"claude-code": fakeSource("claude-code", [{ id: "claude-fable-5" }], calls)
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
assert.deepEqual(backend.listModelIds(), [
|
|
171
|
+
"claude-code/claude-fable-5",
|
|
172
|
+
"velum-fable-5"
|
|
173
|
+
]);
|
|
174
|
+
assert.equal(backend.servesModel("velum-fable-5"), true);
|
|
175
|
+
assert.deepEqual(backend.resolveModelRoute("velum-fable-5"), {
|
|
176
|
+
publicId: "velum-fable-5",
|
|
177
|
+
nativeId: "claude-fable-5",
|
|
178
|
+
provider: "claude-code"
|
|
179
|
+
});
|
|
180
|
+
assert.equal(backend.modelInfo("velum-fable-5")?.id, "velum-fable-5");
|
|
181
|
+
assert.equal(backend.modelInfo("velum-fable-5")?.nativeModel, "claude-fable-5");
|
|
182
|
+
await backend.chat({ model: "velum-fable-5", messages: [] });
|
|
183
|
+
assert.deepEqual(calls, [
|
|
184
|
+
{ source: "claude-code", model: "claude-fable-5" }
|
|
185
|
+
]);
|
|
186
|
+
});
|
|
187
|
+
test("model aliases reject bad shapes and unknown targets", async () => {
|
|
188
|
+
assert.throws(() => parseRouterConfig({
|
|
189
|
+
providers: { openai: {} },
|
|
190
|
+
modelAliases: { "bad/alias": "openai/gpt-5.5" }
|
|
191
|
+
}), /must not contain "\/"/);
|
|
192
|
+
assert.throws(() => parseRouterConfig({
|
|
193
|
+
providers: { openai: {} },
|
|
194
|
+
modelAliases: { "velum-fable-5": "claude-code/claude-fable-5" }
|
|
195
|
+
}), /provider "claude-code" is not configured/);
|
|
196
|
+
await assert.rejects(CatalogBackend.create({
|
|
197
|
+
config: {
|
|
198
|
+
providers: { openai: {} },
|
|
199
|
+
modelAliases: { "velum-gpt": "openai/not-discovered" }
|
|
200
|
+
},
|
|
201
|
+
sources: { openai: fakeSource("openai", [{ id: "gpt-5.5" }]) }
|
|
202
|
+
}), /targets "openai\/not-discovered", which no configured provider serves/);
|
|
203
|
+
await assert.rejects(CatalogBackend.create({
|
|
204
|
+
config: {
|
|
205
|
+
providers: { openai: {} },
|
|
206
|
+
modelAliases: { "openai/gpt-5.5": "openai/gpt-5.5" }
|
|
207
|
+
},
|
|
208
|
+
sources: { openai: fakeSource("openai", [{ id: "gpt-5.5" }]) }
|
|
209
|
+
}), /must not contain "\/"/);
|
|
210
|
+
});
|
|
159
211
|
test("model info exhaustively classifies subscription and proxy billing", async () => {
|
|
160
212
|
const backend = await CatalogBackend.create({
|
|
161
213
|
config: {
|
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.7",
|
|
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-tracing": "0.9.
|
|
32
|
-
"@velum-labs/routekit-runtime": "0.9.
|
|
33
|
-
"@velum-labs/routekit-registry": "0.9.
|
|
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"
|
|
34
34
|
},
|
|
35
35
|
"keywords": [
|
|
36
36
|
"routekit",
|