@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.
@@ -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 { ANTHROPIC_MESSAGE_CONTENT, ANTHROPIC_REQUEST_METADATA, attachReasoningSelection, attachReasoningSelectionError, anthropicReasoningDetailsOf } from "./openai-chat-wire.js";
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
- attachAnthropicContent(assistant, nativeContent);
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
- Object.defineProperty(chat, ANTHROPIC_REQUEST_METADATA, {
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
  *
@@ -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
- if (typeof model !== "string" || model.length === 0 || servedIds.includes(model)) {
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
- const stripped = stripCursorNamespace(model);
68
- if (stripped !== undefined && servedIds.includes(stripped)) {
69
- return stripped;
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 servedIds.find((id) => id.includes("/") && cursorModelAliasId(id) === model);
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
- translateReasoning(body, translated);
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
- translated.reasoning_effort = effort;
288
- attachReasoningSelection(translated, { mode: "effort", effort });
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?: AnthropicReasoningDetail[];
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?: AnthropicReasoningDetail[];
163
+ reasoning_details?: CanonicalReasoningDetail[];
88
164
  tool_calls?: OpenAiToolCall[];
89
165
  };
90
166
  finish_reason?: string | null;