@velum-labs/routekit-gateway 0.9.6 → 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.
- package/dist/adapters/cursor.d.ts +11 -11
- package/dist/adapters/cursor.js +17 -12
- package/dist/router.d.ts +1 -0
- package/dist/router.js +20 -0
- package/dist/server.js +7 -6
- package/dist/test/cursor.test.js +42 -11
- package/dist/test/router.test.js +52 -0
- 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
|
-
return id.
|
|
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.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 "/"; alias keys must stay distinct from namespaced model ids`);
|
|
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
|
@@ -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,16 +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");
|
|
52
|
+
assert.equal(cursorModelAliasId("openrouter/moonshotai/kimi-k2-thinking"), "openrouter-moonshotai-kimi-k2-thinking", "legacy dashed spelling still respells every slash");
|
|
54
53
|
const served = ["claude-code/claude-fable-5", "openai/gpt-4o", "route-primary"];
|
|
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.
|
|
55
59
|
assert.equal(resolveCursorModelAlias("claude-code-claude-fable-5", served), "claude-code/claude-fable-5");
|
|
56
60
|
assert.equal(resolveCursorModelAlias("openai-gpt-4o", served), "openai/gpt-4o");
|
|
57
61
|
// Served-as-spelled ids and unknown names never rewrite.
|
|
58
62
|
assert.equal(resolveCursorModelAlias("route-primary", served), undefined);
|
|
59
63
|
assert.equal(resolveCursorModelAlias("claude-fable-5", served), undefined);
|
|
64
|
+
assert.equal(resolveCursorModelAlias("routekit/", served), undefined);
|
|
60
65
|
assert.equal(resolveCursorModelAlias(undefined, served), undefined);
|
|
61
66
|
});
|
|
62
67
|
test("Cursor hybrid detection rejects unrelated bodies", () => {
|
|
@@ -104,13 +109,13 @@ test("RouteKit serves the Cursor hybrid through its neutral HTTP boundary", asyn
|
|
|
104
109
|
});
|
|
105
110
|
const models = await fetch(`${gateway.url()}/v1/cursor/models`);
|
|
106
111
|
assert.equal(models.status, 200);
|
|
107
|
-
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"]);
|
|
108
113
|
}
|
|
109
114
|
finally {
|
|
110
115
|
await gateway.close();
|
|
111
116
|
}
|
|
112
117
|
});
|
|
113
|
-
test("Cursor route
|
|
118
|
+
test("Cursor route namespaces advertised ids and resolves them on ingress", async () => {
|
|
114
119
|
let received;
|
|
115
120
|
const backend = {
|
|
116
121
|
defaultModel: "claude-code/claude-fable-5",
|
|
@@ -133,15 +138,31 @@ test("Cursor route resolves dashed model aliases to namespaced ids", async () =>
|
|
|
133
138
|
object: "list",
|
|
134
139
|
data: [
|
|
135
140
|
{ id: "claude-code/claude-fable-5", object: "model" },
|
|
136
|
-
{ id: "openai/gpt-4o", object: "model" }
|
|
141
|
+
{ id: "openai/gpt-4o", object: "model" },
|
|
142
|
+
{ id: "gemini-proxy/gemini-zzz-9", object: "model" }
|
|
137
143
|
]
|
|
138
144
|
})),
|
|
139
|
-
listModelIds: () => [
|
|
145
|
+
listModelIds: () => [
|
|
146
|
+
"claude-code/claude-fable-5",
|
|
147
|
+
"openai/gpt-4o",
|
|
148
|
+
"gemini-proxy/gemini-zzz-9"
|
|
149
|
+
],
|
|
140
150
|
embeddings: () => Promise.resolve(new Response(null, { status: 501 }))
|
|
141
151
|
};
|
|
142
152
|
const gateway = await startGateway({ backend });
|
|
143
153
|
try {
|
|
144
|
-
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`, {
|
|
145
166
|
method: "POST",
|
|
146
167
|
headers: { "content-type": "application/json" },
|
|
147
168
|
body: JSON.stringify({
|
|
@@ -149,12 +170,22 @@ test("Cursor route resolves dashed model aliases to namespaced ids", async () =>
|
|
|
149
170
|
messages: [{ role: "user", content: "hi" }]
|
|
150
171
|
})
|
|
151
172
|
});
|
|
152
|
-
assert.equal(
|
|
173
|
+
assert.equal(legacy.status, 200);
|
|
153
174
|
assert.equal(received?.model, "claude-code/claude-fable-5");
|
|
154
|
-
// 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).
|
|
155
177
|
const models = await fetch(`${gateway.url()}/v1/cursor/models`);
|
|
156
178
|
assert.equal(models.status, 200);
|
|
157
|
-
|
|
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
|
+
}
|
|
158
189
|
}
|
|
159
190
|
finally {
|
|
160
191
|
await gateway.close();
|
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.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-
|
|
31
|
-
"@velum-labs/routekit-
|
|
32
|
-
"@velum-labs/routekit-runtime": "0.9.
|
|
33
|
-
"@velum-labs/routekit-
|
|
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",
|