@velum-labs/routekit-gateway 0.9.9 → 0.10.0
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/anthropic.js +3 -12
- package/dist/adapters/cursor.d.ts +20 -0
- package/dist/adapters/cursor.js +104 -10
- package/dist/adapters/openai-chat-wire.d.ts +78 -2
- package/dist/adapters/openai-chat-wire.js +619 -15
- package/dist/adapters/responses-stream.js +66 -31
- package/dist/adapters/responses-usage.d.ts +14 -0
- package/dist/adapters/responses-usage.js +31 -0
- package/dist/adapters/responses.d.ts +11 -13
- package/dist/adapters/responses.js +190 -37
- package/dist/adapters/server-tool-loop.js +46 -6
- package/dist/backend.d.ts +7 -0
- package/dist/backend.js +36 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/provider-backends.d.ts +1 -0
- package/dist/provider-backends.js +326 -75
- package/dist/router.d.ts +7 -0
- package/dist/router.js +37 -6
- package/dist/server.js +38 -11
- package/dist/sse/chat-assembler.d.ts +6 -2
- package/dist/sse/chat-assembler.js +40 -11
- package/dist/test/chat.test.js +55 -0
- package/dist/test/cursor.test.js +243 -3
- package/dist/test/provider-backends.test.js +766 -3
- package/dist/test/responses.test.js +624 -3
- package/dist/test/routed-backend.test.js +13 -3
- package/dist/test/router.test.js +115 -2
- package/dist/test/sse-codec.test.js +8 -3
- package/dist/test/web-search-loop.test.js +280 -1
- package/package.json +5 -5
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { estimateTokens, randomId } from "@velum-labs/routekit-runtime";
|
|
11
11
|
import { SseDecoder, SseParseError } from "../sse/parse.js";
|
|
12
|
-
import {
|
|
12
|
+
import { attachAnthropicMessageContent, attachAnthropicRequestMetadata, attachReasoningSelection, attachReasoningSelectionError, anthropicReasoningDetailsOf } from "./openai-chat-wire.js";
|
|
13
13
|
import { droppedField } from "./dropped.js";
|
|
14
14
|
import { unwrapUpstreamError } from "./upstream-error.js";
|
|
15
15
|
import { composeServerToolStream, runBufferedServerToolLoop, serverToolMarkerOf } from "./server-tool-loop.js";
|
|
@@ -122,12 +122,6 @@ function toolResultContent(result) {
|
|
|
122
122
|
const text = blockText(result.content);
|
|
123
123
|
return result.is_error === true ? `[tool_error]\n${text}` : text;
|
|
124
124
|
}
|
|
125
|
-
function attachAnthropicContent(message, content) {
|
|
126
|
-
Object.defineProperty(message, ANTHROPIC_MESSAGE_CONTENT, {
|
|
127
|
-
value: [...content],
|
|
128
|
-
enumerable: true
|
|
129
|
-
});
|
|
130
|
-
}
|
|
131
125
|
/**
|
|
132
126
|
* Translate an Anthropic Messages request to an OpenAI Chat Completions body.
|
|
133
127
|
* The upstream model is always the backend's own model (Claude Code sends a
|
|
@@ -266,7 +260,7 @@ export function anthropicToChat(body, backendModel, options = {}) {
|
|
|
266
260
|
if (toolCalls.length > 0)
|
|
267
261
|
assistant.tool_calls = toolCalls;
|
|
268
262
|
if (hasReplayableThinking)
|
|
269
|
-
|
|
263
|
+
attachAnthropicMessageContent(assistant, nativeContent);
|
|
270
264
|
messages.push(assistant);
|
|
271
265
|
}
|
|
272
266
|
continue;
|
|
@@ -343,10 +337,7 @@ export function anthropicToChat(body, backendModel, options = {}) {
|
|
|
343
337
|
...(body.output_config !== undefined ? { output_config: body.output_config } : {})
|
|
344
338
|
};
|
|
345
339
|
if (Object.keys(metadata).length > 0) {
|
|
346
|
-
|
|
347
|
-
value: metadata,
|
|
348
|
-
enumerable: true
|
|
349
|
-
});
|
|
340
|
+
attachAnthropicRequestMetadata(chat, metadata);
|
|
350
341
|
}
|
|
351
342
|
if (Array.isArray(body.stop_sequences) && body.stop_sequences.length > 0) {
|
|
352
343
|
chat.stop = body.stop_sequences;
|
|
@@ -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 type { ModelReasoningCapabilities } from "@velum-labs/routekit-contracts";
|
|
17
18
|
type JsonObject = Record<string, unknown>;
|
|
18
19
|
/**
|
|
19
20
|
* Whether a parsed request body is routable by the cursor route: a JSON
|
|
@@ -32,6 +33,25 @@ export declare function isCursorChatBody(body: unknown): body is JsonObject;
|
|
|
32
33
|
* the OpenAI base-URL override. Deprecated: prefer `cursorModelName`.
|
|
33
34
|
*/
|
|
34
35
|
export declare function cursorModelAliasId(id: string): string;
|
|
36
|
+
export type CursorModelSelection = {
|
|
37
|
+
model: string;
|
|
38
|
+
reasoningEffort?: string;
|
|
39
|
+
};
|
|
40
|
+
/**
|
|
41
|
+
* Expand one served model into the opaque ids Cursor can put in its picker.
|
|
42
|
+
*
|
|
43
|
+
* Cursor's OpenAI-compatible BYOK path does not expose its reasoning picker,
|
|
44
|
+
* so each discovered effort is represented as a model-name variant instead.
|
|
45
|
+
*/
|
|
46
|
+
export declare function cursorModelVariants(id: string, reasoning: unknown): CursorModelSelection[];
|
|
47
|
+
/**
|
|
48
|
+
* Resolve a Cursor-facing model variant back to its served model and effort.
|
|
49
|
+
*
|
|
50
|
+
* Exact served ids win before suffix parsing, so a provider model whose real
|
|
51
|
+
* id contains a colon remains addressable. Effort aliases are accepted but
|
|
52
|
+
* normalized to the provider's canonical id.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolveCursorModelSelection(model: unknown, servedIds: readonly string[], reasoningCapabilities?: (model: string) => ModelReasoningCapabilities | undefined): CursorModelSelection | undefined;
|
|
35
55
|
/**
|
|
36
56
|
* Resolve a Cursor-facing model name back to a served id.
|
|
37
57
|
*
|
package/dist/adapters/cursor.js
CHANGED
|
@@ -14,9 +14,9 @@
|
|
|
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
|
+
import { cursorModelName, resolveReasoningEffort, stripCursorNamespace } from "@velum-labs/routekit-contracts";
|
|
18
18
|
import { droppedField } from "./dropped.js";
|
|
19
|
-
import { attachReasoningSelection, attachReasoningSelectionError } from "./openai-chat-wire.js";
|
|
19
|
+
import { attachReasoningSelection, attachReasoningSelectionError, hasExplicitReasoningSelection, reasoningSelectionErrorOf, reasoningSelectionOf } from "./openai-chat-wire.js";
|
|
20
20
|
/** Fields copied through unchanged when present and non-null. */
|
|
21
21
|
const PASSTHROUGH_FIELDS = ["model", "temperature", "top_p", "top_k", "tool_choice", "stream", "parallel_tool_calls"];
|
|
22
22
|
/**
|
|
@@ -53,6 +53,56 @@ export function isCursorChatBody(body) {
|
|
|
53
53
|
export function cursorModelAliasId(id) {
|
|
54
54
|
return id.replaceAll("/", "-");
|
|
55
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* Expand one served model into the opaque ids Cursor can put in its picker.
|
|
58
|
+
*
|
|
59
|
+
* Cursor's OpenAI-compatible BYOK path does not expose its reasoning picker,
|
|
60
|
+
* so each discovered effort is represented as a model-name variant instead.
|
|
61
|
+
*/
|
|
62
|
+
export function cursorModelVariants(id, reasoning) {
|
|
63
|
+
const variants = [{ model: cursorModelName(id) }];
|
|
64
|
+
if (!isObject(reasoning) || reasoning.status !== "supported")
|
|
65
|
+
return variants;
|
|
66
|
+
if (!Array.isArray(reasoning.efforts))
|
|
67
|
+
return variants;
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const option of reasoning.efforts) {
|
|
70
|
+
if (!isObject(option) || typeof option.id !== "string" || option.id.length === 0) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (seen.has(option.id))
|
|
74
|
+
continue;
|
|
75
|
+
seen.add(option.id);
|
|
76
|
+
variants.push({
|
|
77
|
+
model: cursorModelName(`${id}:${option.id}`),
|
|
78
|
+
reasoningEffort: option.id
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
return variants;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve a Cursor-facing model variant back to its served model and effort.
|
|
85
|
+
*
|
|
86
|
+
* Exact served ids win before suffix parsing, so a provider model whose real
|
|
87
|
+
* id contains a colon remains addressable. Effort aliases are accepted but
|
|
88
|
+
* normalized to the provider's canonical id.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveCursorModelSelection(model, servedIds, reasoningCapabilities) {
|
|
91
|
+
if (typeof model !== "string" || model.length === 0 || servedIds.includes(model)) {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
const stripped = stripCursorNamespace(model);
|
|
95
|
+
const candidate = stripped ?? model;
|
|
96
|
+
if (servedIds.includes(candidate))
|
|
97
|
+
return { model: candidate };
|
|
98
|
+
const suffixed = resolveCursorReasoningSuffix(candidate, servedIds, reasoningCapabilities);
|
|
99
|
+
if (suffixed !== undefined)
|
|
100
|
+
return suffixed;
|
|
101
|
+
const legacy = servedIds.find((id) => id.includes("/") && cursorModelAliasId(id) === candidate);
|
|
102
|
+
if (legacy !== undefined)
|
|
103
|
+
return { model: legacy };
|
|
104
|
+
return resolveLegacyCursorReasoningSuffix(candidate, servedIds, reasoningCapabilities);
|
|
105
|
+
}
|
|
56
106
|
/**
|
|
57
107
|
* Resolve a Cursor-facing model name back to a served id.
|
|
58
108
|
*
|
|
@@ -61,14 +111,45 @@ export function cursorModelAliasId(id) {
|
|
|
61
111
|
* served id or cannot be resolved.
|
|
62
112
|
*/
|
|
63
113
|
export function resolveCursorModelAlias(model, servedIds) {
|
|
64
|
-
|
|
114
|
+
return resolveCursorModelSelection(model, servedIds)?.model;
|
|
115
|
+
}
|
|
116
|
+
function resolveCursorReasoningSuffix(candidate, servedIds, reasoningCapabilities) {
|
|
117
|
+
if (reasoningCapabilities === undefined)
|
|
65
118
|
return undefined;
|
|
119
|
+
for (const id of [...servedIds].sort((left, right) => right.length - left.length)) {
|
|
120
|
+
const prefix = `${id}:`;
|
|
121
|
+
if (!candidate.startsWith(prefix))
|
|
122
|
+
continue;
|
|
123
|
+
const requested = candidate.slice(prefix.length);
|
|
124
|
+
if (requested.length === 0)
|
|
125
|
+
continue;
|
|
126
|
+
const capabilities = reasoningCapabilities(id);
|
|
127
|
+
if (capabilities === undefined || capabilities.status !== "supported")
|
|
128
|
+
continue;
|
|
129
|
+
const effort = resolveReasoningEffort(capabilities, requested);
|
|
130
|
+
if (effort !== undefined)
|
|
131
|
+
return { model: id, reasoningEffort: effort };
|
|
66
132
|
}
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
133
|
+
return undefined;
|
|
134
|
+
}
|
|
135
|
+
function resolveLegacyCursorReasoningSuffix(candidate, servedIds, reasoningCapabilities) {
|
|
136
|
+
if (reasoningCapabilities === undefined)
|
|
137
|
+
return undefined;
|
|
138
|
+
for (const id of servedIds) {
|
|
139
|
+
if (!id.includes("/"))
|
|
140
|
+
continue;
|
|
141
|
+
const prefix = `${cursorModelAliasId(id)}:`;
|
|
142
|
+
if (!candidate.startsWith(prefix))
|
|
143
|
+
continue;
|
|
144
|
+
const requested = candidate.slice(prefix.length);
|
|
145
|
+
const capabilities = reasoningCapabilities(id);
|
|
146
|
+
if (capabilities === undefined || capabilities.status !== "supported")
|
|
147
|
+
continue;
|
|
148
|
+
const effort = resolveReasoningEffort(capabilities, requested);
|
|
149
|
+
if (effort !== undefined)
|
|
150
|
+
return { model: id, reasoningEffort: effort };
|
|
70
151
|
}
|
|
71
|
-
return
|
|
152
|
+
return undefined;
|
|
72
153
|
}
|
|
73
154
|
/**
|
|
74
155
|
* Map a Cursor BYOK request body onto a Chat Completions body.
|
|
@@ -93,11 +174,18 @@ export function translateCursorRequest(body) {
|
|
|
93
174
|
}
|
|
94
175
|
}
|
|
95
176
|
translated.messages = inputItemsToMessages(body.input);
|
|
177
|
+
if (body.x_routekit !== undefined)
|
|
178
|
+
translated.x_routekit = body.x_routekit;
|
|
179
|
+
const originalSelectionError = reasoningSelectionErrorOf(body);
|
|
180
|
+
if (originalSelectionError !== undefined) {
|
|
181
|
+
attachReasoningSelectionError(translated, originalSelectionError);
|
|
182
|
+
}
|
|
96
183
|
const tools = translateTools(body.tools);
|
|
97
184
|
if (tools !== undefined)
|
|
98
185
|
translated.tools = tools;
|
|
99
186
|
translateSampling(body, translated);
|
|
100
|
-
|
|
187
|
+
if (originalSelectionError === undefined)
|
|
188
|
+
translateReasoning(body, translated);
|
|
101
189
|
translateTextFormat(body, translated);
|
|
102
190
|
if (translated.stream === true) {
|
|
103
191
|
translated.stream_options = { include_usage: true };
|
|
@@ -275,6 +363,10 @@ function translateSampling(body, translated) {
|
|
|
275
363
|
}
|
|
276
364
|
}
|
|
277
365
|
function translateReasoning(body, translated) {
|
|
366
|
+
const hasCanonicalSelection = hasExplicitReasoningSelection(body);
|
|
367
|
+
if (hasCanonicalSelection) {
|
|
368
|
+
attachReasoningSelection(translated, reasoningSelectionOf(body));
|
|
369
|
+
}
|
|
278
370
|
const reasoning = body.reasoning;
|
|
279
371
|
if (reasoning === undefined || reasoning === null)
|
|
280
372
|
return;
|
|
@@ -284,8 +376,10 @@ function translateReasoning(body, translated) {
|
|
|
284
376
|
}
|
|
285
377
|
const effort = reasoning.effort;
|
|
286
378
|
if (typeof effort === "string" && effort.length > 0) {
|
|
287
|
-
|
|
288
|
-
|
|
379
|
+
if (!hasCanonicalSelection) {
|
|
380
|
+
translated.reasoning_effort = effort;
|
|
381
|
+
attachReasoningSelection(translated, { mode: "effort", effort });
|
|
382
|
+
}
|
|
289
383
|
return;
|
|
290
384
|
}
|
|
291
385
|
attachReasoningSelectionError(translated, "reasoning.effort must be a non-empty string");
|
|
@@ -12,6 +12,13 @@ export type OpenAiToolCall = {
|
|
|
12
12
|
* `reasoning` string. Native Anthropic streams need block lifecycle and opaque
|
|
13
13
|
* signatures to survive a round trip; other dialects can ignore this field.
|
|
14
14
|
*/
|
|
15
|
+
export type GoogleThoughtDetail = {
|
|
16
|
+
type: "google_thought";
|
|
17
|
+
index: number;
|
|
18
|
+
thought?: string;
|
|
19
|
+
thoughtSignature: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function googleThoughtDetailsOf(value: unknown): GoogleThoughtDetail[];
|
|
15
22
|
export type AnthropicReasoningDetail = {
|
|
16
23
|
type: "thinking";
|
|
17
24
|
index: number;
|
|
@@ -51,8 +58,76 @@ export declare const ANTHROPIC_REQUEST_METADATA: unique symbol;
|
|
|
51
58
|
export declare const ANTHROPIC_MESSAGE_CONTENT: unique symbol;
|
|
52
59
|
export declare const REASONING_SELECTION: unique symbol;
|
|
53
60
|
export declare const REASONING_SELECTION_ERROR: unique symbol;
|
|
61
|
+
/**
|
|
62
|
+
* Opaque OpenAI Responses reasoning state carried only inside the gateway.
|
|
63
|
+
* The symbol key survives in-process object spreads but is omitted by JSON,
|
|
64
|
+
* while the value itself is plain serializable data for deterministic replay
|
|
65
|
+
* by a Responses-capable provider backend.
|
|
66
|
+
*/
|
|
67
|
+
export declare const RESPONSES_REASONING_METADATA: unique symbol;
|
|
68
|
+
export declare const GOOGLE_TOOL_CALL_INDEXES: unique symbol;
|
|
69
|
+
export type ResponsesReasoningItem = {
|
|
70
|
+
type: "reasoning";
|
|
71
|
+
id?: string;
|
|
72
|
+
summary?: unknown;
|
|
73
|
+
content?: unknown;
|
|
74
|
+
encrypted_content: string;
|
|
75
|
+
};
|
|
76
|
+
export type ResponsesReasoningMetadata = {
|
|
77
|
+
items: ResponsesReasoningItem[];
|
|
78
|
+
includeEncryptedContent: boolean;
|
|
79
|
+
};
|
|
80
|
+
export declare function attachResponsesReasoningMetadata(target: Record<PropertyKey, unknown>, metadata: ResponsesReasoningMetadata): void;
|
|
81
|
+
export declare function responsesReasoningMetadataErrorOf(value: unknown): string | undefined;
|
|
82
|
+
export declare function responsesReasoningMetadataOf(value: unknown): ResponsesReasoningMetadata | undefined;
|
|
83
|
+
/**
|
|
84
|
+
* Explicitly namespaced, JSON-safe fidelity metadata. Chat providers must strip
|
|
85
|
+
* this extension before egress; compound/proxy layers may serialize it while
|
|
86
|
+
* reconstructing a request for another RouteKit gateway.
|
|
87
|
+
*/
|
|
88
|
+
export type RouteKitReasoningEnvelope = {
|
|
89
|
+
version: 1;
|
|
90
|
+
selection?: ReasoningSelection;
|
|
91
|
+
anthropic?: {
|
|
92
|
+
request?: AnthropicRequestMetadata;
|
|
93
|
+
};
|
|
94
|
+
responses?: ResponsesReasoningMetadata;
|
|
95
|
+
};
|
|
96
|
+
export type RouteKitMessageEnvelope = {
|
|
97
|
+
version: 1;
|
|
98
|
+
anthropic?: {
|
|
99
|
+
content?: AnthropicNativeContentBlock[];
|
|
100
|
+
};
|
|
101
|
+
responses?: ResponsesReasoningMetadata;
|
|
102
|
+
google?: {
|
|
103
|
+
toolCallIndexes?: Record<string, number>;
|
|
104
|
+
};
|
|
105
|
+
};
|
|
106
|
+
export declare const ROUTEKIT_EXTENSION_KEY: "x_routekit";
|
|
107
|
+
export declare function attachAnthropicRequestMetadata(target: Record<PropertyKey, unknown>, metadata: AnthropicRequestMetadata): void;
|
|
108
|
+
export declare function anthropicRequestMetadataOf(value: unknown): AnthropicRequestMetadata | undefined;
|
|
109
|
+
export declare function hasExplicitReasoningSelection(value: unknown): boolean;
|
|
110
|
+
export declare function attachAnthropicMessageContent(target: Record<PropertyKey, unknown>, content: readonly AnthropicNativeContentBlock[]): void;
|
|
111
|
+
export declare function anthropicMessageContentOf(value: unknown): AnthropicNativeContentBlock[] | undefined;
|
|
112
|
+
export declare function attachGoogleToolCallIndexes(target: Record<PropertyKey, unknown>, indexes: Readonly<Record<string, number>>): void;
|
|
113
|
+
export declare function googleToolCallIndexesOf(value: unknown): Readonly<Record<string, number>>;
|
|
114
|
+
/** Remove the private namespaced extension at a final non-RouteKit provider boundary. */
|
|
115
|
+
export declare function withoutRouteKitExtensions(value: unknown): unknown;
|
|
54
116
|
export declare function attachReasoningSelection(target: Record<PropertyKey, unknown>, selection: ReasoningSelection): void;
|
|
55
117
|
export declare function attachReasoningSelectionError(target: Record<PropertyKey, unknown>, message: string): void;
|
|
118
|
+
/**
|
|
119
|
+
* Replace all request-local reasoning state with one authoritative selection.
|
|
120
|
+
*
|
|
121
|
+
* The clone is intentional: attached symbol metadata is non-configurable on
|
|
122
|
+
* its source object, while object spread creates replaceable own properties.
|
|
123
|
+
*/
|
|
124
|
+
export declare function withReasoningSelection(target: Record<string, unknown>, selection: ReasoningSelection): Record<string, unknown>;
|
|
125
|
+
export type RouteKitRequestValidationError = {
|
|
126
|
+
code: "invalid_reasoning_control" | "invalid_reasoning_metadata";
|
|
127
|
+
path: string;
|
|
128
|
+
message: string;
|
|
129
|
+
};
|
|
130
|
+
export declare function routeKitRequestValidationErrorOf(value: unknown): RouteKitRequestValidationError | undefined;
|
|
56
131
|
export declare function reasoningSelectionErrorOf(value: unknown): string | undefined;
|
|
57
132
|
export declare function reasoningSelectionOf(value: unknown): ReasoningSelection;
|
|
58
133
|
export type AnthropicNativeContentBlock = {
|
|
@@ -71,11 +146,12 @@ export type AnthropicNativeContentBlock = {
|
|
|
71
146
|
name: string;
|
|
72
147
|
input: unknown;
|
|
73
148
|
};
|
|
149
|
+
export type CanonicalReasoningDetail = AnthropicReasoningDetail | GoogleThoughtDetail;
|
|
74
150
|
export type OpenAiDelta = {
|
|
75
151
|
content?: string | null;
|
|
76
152
|
reasoning?: string | null;
|
|
77
153
|
reasoning_content?: string | null;
|
|
78
|
-
reasoning_details?:
|
|
154
|
+
reasoning_details?: CanonicalReasoningDetail[];
|
|
79
155
|
tool_calls?: OpenAiToolCall[];
|
|
80
156
|
};
|
|
81
157
|
export type OpenAiChoice = {
|
|
@@ -84,7 +160,7 @@ export type OpenAiChoice = {
|
|
|
84
160
|
content?: string | null;
|
|
85
161
|
reasoning?: string | null;
|
|
86
162
|
reasoning_content?: string | null;
|
|
87
|
-
reasoning_details?:
|
|
163
|
+
reasoning_details?: CanonicalReasoningDetail[];
|
|
88
164
|
tool_calls?: OpenAiToolCall[];
|
|
89
165
|
};
|
|
90
166
|
finish_reason?: string | null;
|