@velum-labs/routekit-gateway 0.9.8 → 0.9.10
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.
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { randomId } from "@velum-labs/routekit-runtime";
|
|
2
2
|
import { joinPath } from "./backend.js";
|
|
3
|
+
import { droppedField } from "./adapters/dropped.js";
|
|
3
4
|
import { SseDecoder, SseParseError } from "./sse/parse.js";
|
|
4
5
|
import { ANTHROPIC_MESSAGE_CONTENT, ANTHROPIC_REQUEST_METADATA, anthropicReasoningDetailsOf, reasoningSelectionOf } from "./adapters/openai-chat-wire.js";
|
|
5
6
|
class HttpProviderBackend {
|
|
@@ -135,6 +136,70 @@ function mapSse(response, mapper) {
|
|
|
135
136
|
headers: { "content-type": "text/event-stream; charset=utf-8" }
|
|
136
137
|
});
|
|
137
138
|
}
|
|
139
|
+
// Stands in for a closing turn that translated to nothing. Anthropic rejects
|
|
140
|
+
// empty and whitespace-only text, so the stand-in has to say something.
|
|
141
|
+
const BLANK_TURN_PLACEHOLDER = "(continue)";
|
|
142
|
+
function anthropicContentIsEmpty(message) {
|
|
143
|
+
return Array.isArray(message?.content) && message.content.length === 0;
|
|
144
|
+
}
|
|
145
|
+
function anthropicImageBlock(part) {
|
|
146
|
+
const imageUrl = part.image_url;
|
|
147
|
+
const url = typeof imageUrl === "string"
|
|
148
|
+
? imageUrl
|
|
149
|
+
: typeof imageUrl === "object" &&
|
|
150
|
+
imageUrl !== null &&
|
|
151
|
+
typeof imageUrl.url === "string"
|
|
152
|
+
? imageUrl.url
|
|
153
|
+
: undefined;
|
|
154
|
+
if (url === undefined)
|
|
155
|
+
return undefined;
|
|
156
|
+
const dataUrl = /^data:([^;,]+);base64,(.+)$/s.exec(url);
|
|
157
|
+
if (dataUrl !== null) {
|
|
158
|
+
return {
|
|
159
|
+
type: "image",
|
|
160
|
+
source: { type: "base64", media_type: dataUrl[1], data: dataUrl[2] }
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
if (/^https?:\/\//i.test(url))
|
|
164
|
+
return { type: "image", source: { type: "url", url } };
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Translate OpenAI chat content into Anthropic content blocks.
|
|
169
|
+
*
|
|
170
|
+
* Anthropic rejects text blocks that are empty or whitespace-only, so those are
|
|
171
|
+
* skipped here rather than sent upstream. Callers must handle the resulting
|
|
172
|
+
* empty block list; see {@link anthropicMessages}.
|
|
173
|
+
*/
|
|
174
|
+
function anthropicContentBlocks(content, context) {
|
|
175
|
+
if (typeof content === "string") {
|
|
176
|
+
return content.trim().length > 0 ? [{ type: "text", text: content }] : [];
|
|
177
|
+
}
|
|
178
|
+
if (!Array.isArray(content))
|
|
179
|
+
return [];
|
|
180
|
+
const blocks = [];
|
|
181
|
+
for (const part of content) {
|
|
182
|
+
if (typeof part !== "object" || part === null)
|
|
183
|
+
continue;
|
|
184
|
+
const record = part;
|
|
185
|
+
if (record.type === "image_url") {
|
|
186
|
+
const image = anthropicImageBlock(record);
|
|
187
|
+
if (image !== undefined)
|
|
188
|
+
blocks.push(image);
|
|
189
|
+
else
|
|
190
|
+
droppedField("anthropic", "image_url", context);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (typeof record.text === "string") {
|
|
194
|
+
if (record.text.trim().length > 0)
|
|
195
|
+
blocks.push({ type: "text", text: record.text });
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (typeof record.type === "string")
|
|
199
|
+
droppedField("anthropic", record.type, context);
|
|
200
|
+
}
|
|
201
|
+
return blocks;
|
|
202
|
+
}
|
|
138
203
|
function anthropicNativeContent(message) {
|
|
139
204
|
const content = message[ANTHROPIC_MESSAGE_CONTENT];
|
|
140
205
|
if (Array.isArray(content))
|
|
@@ -155,7 +220,7 @@ function anthropicNativeContent(message) {
|
|
|
155
220
|
signature: detail.signature ?? ""
|
|
156
221
|
});
|
|
157
222
|
const text = textContent(message.content);
|
|
158
|
-
if (text.length > 0)
|
|
223
|
+
if (text.trim().length > 0)
|
|
159
224
|
native.push({ type: "text", text });
|
|
160
225
|
for (const call of message.tool_calls ?? []) {
|
|
161
226
|
let input = {};
|
|
@@ -219,10 +284,7 @@ function anthropicMessages(body, model) {
|
|
|
219
284
|
}
|
|
220
285
|
];
|
|
221
286
|
}
|
|
222
|
-
const content =
|
|
223
|
-
const text = textContent(message.content);
|
|
224
|
-
if (text.length > 0)
|
|
225
|
-
content.push({ type: "text", text });
|
|
287
|
+
const content = anthropicContentBlocks(message.content, `${message.role ?? "user"}_message`);
|
|
226
288
|
for (const call of message.tool_calls ?? []) {
|
|
227
289
|
let input = {};
|
|
228
290
|
try {
|
|
@@ -240,6 +302,14 @@ function anthropicMessages(body, model) {
|
|
|
240
302
|
}
|
|
241
303
|
return [{ role: message.role === "assistant" ? "assistant" : "user", content }];
|
|
242
304
|
});
|
|
305
|
+
// Anthropic rejects any user turn whose content list is empty, and OpenAI
|
|
306
|
+
// clients legitimately send blank turns. Adjacent turns of the same role are
|
|
307
|
+
// coalesced upstream, so dropping a blank turn loses nothing — except when it
|
|
308
|
+
// was the closing turn, which models that forbid assistant prefill require.
|
|
309
|
+
const kept = messages.filter((message) => !anthropicContentIsEmpty(message));
|
|
310
|
+
if (anthropicContentIsEmpty(messages.at(-1)) && kept.at(-1)?.role !== "user") {
|
|
311
|
+
kept.push({ role: "user", content: [{ type: "text", text: BLANK_TURN_PLACEHOLDER }] });
|
|
312
|
+
}
|
|
243
313
|
const maxTokens = body.max_completion_tokens ?? body.max_tokens ?? 4096;
|
|
244
314
|
const metadata = anthropicMetadata(body);
|
|
245
315
|
const selection = reasoningSelectionOf(body);
|
|
@@ -255,10 +325,10 @@ function anthropicMessages(body, model) {
|
|
|
255
325
|
const toolChoice = anthropicToolChoice(body.tool_choice, body.parallel_tool_calls);
|
|
256
326
|
return {
|
|
257
327
|
model,
|
|
258
|
-
messages,
|
|
328
|
+
messages: kept,
|
|
259
329
|
max_tokens: maxTokens,
|
|
260
330
|
stream: body.stream === true,
|
|
261
|
-
...(system.length > 0 ? { system } : {}),
|
|
331
|
+
...(system.trim().length > 0 ? { system } : {}),
|
|
262
332
|
...(body.temperature !== undefined ? { temperature: body.temperature } : {}),
|
|
263
333
|
...(body.top_p !== undefined ? { top_p: body.top_p } : {}),
|
|
264
334
|
...(body.top_k !== undefined ? { top_k: body.top_k } : {}),
|
|
@@ -58,6 +58,122 @@ test("Anthropic egress preserves tools and normalizes the response", async () =>
|
|
|
58
58
|
globalThis.fetch = original;
|
|
59
59
|
}
|
|
60
60
|
});
|
|
61
|
+
test("Anthropic egress drops blank turns and translates image parts", async () => {
|
|
62
|
+
const original = globalThis.fetch;
|
|
63
|
+
let request;
|
|
64
|
+
globalThis.fetch = async (input, init) => {
|
|
65
|
+
request = new Request(input, init);
|
|
66
|
+
return Response.json({
|
|
67
|
+
id: "msg_blank",
|
|
68
|
+
content: [{ type: "text", text: "ok" }],
|
|
69
|
+
usage: { input_tokens: 1, output_tokens: 1 }
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
try {
|
|
73
|
+
const backend = new AnthropicBackend({
|
|
74
|
+
baseUrl: "https://api.anthropic.test/v1",
|
|
75
|
+
apiKey: "secret",
|
|
76
|
+
defaultModel: "claude-test"
|
|
77
|
+
});
|
|
78
|
+
await backend.chat({
|
|
79
|
+
messages: [
|
|
80
|
+
{ role: "system", content: "be brief" },
|
|
81
|
+
{ role: "user", content: "first" },
|
|
82
|
+
{ role: "assistant", content: "reply" },
|
|
83
|
+
{ role: "user", content: "" },
|
|
84
|
+
{ role: "user", content: [] },
|
|
85
|
+
{ role: "user", content: null },
|
|
86
|
+
{ role: "user", content: " " },
|
|
87
|
+
{
|
|
88
|
+
role: "user",
|
|
89
|
+
content: [
|
|
90
|
+
{ type: "image_url", image_url: { url: "data:image/png;base64,AAAB" } }
|
|
91
|
+
]
|
|
92
|
+
}
|
|
93
|
+
]
|
|
94
|
+
});
|
|
95
|
+
const outbound = (await request?.json());
|
|
96
|
+
assert.equal(outbound.system, "be brief");
|
|
97
|
+
assert.deepEqual(outbound.messages.map((message) => message.role), ["user", "assistant", "user"]);
|
|
98
|
+
assert.ok(outbound.messages.every((message) => message.content.length > 0));
|
|
99
|
+
assert.deepEqual(outbound.messages[2]?.content, [
|
|
100
|
+
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAAB" } }
|
|
101
|
+
]);
|
|
102
|
+
}
|
|
103
|
+
finally {
|
|
104
|
+
globalThis.fetch = original;
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
test("Anthropic egress keeps a closing user turn when the caller's is blank", async () => {
|
|
108
|
+
const original = globalThis.fetch;
|
|
109
|
+
let request;
|
|
110
|
+
globalThis.fetch = async (input, init) => {
|
|
111
|
+
request = new Request(input, init);
|
|
112
|
+
return Response.json({
|
|
113
|
+
id: "msg_closing",
|
|
114
|
+
content: [{ type: "text", text: "ok" }],
|
|
115
|
+
usage: { input_tokens: 1, output_tokens: 1 }
|
|
116
|
+
});
|
|
117
|
+
};
|
|
118
|
+
try {
|
|
119
|
+
const backend = new AnthropicBackend({
|
|
120
|
+
baseUrl: "https://api.anthropic.test/v1",
|
|
121
|
+
apiKey: "secret",
|
|
122
|
+
defaultModel: "claude-test"
|
|
123
|
+
});
|
|
124
|
+
await backend.chat({
|
|
125
|
+
messages: [
|
|
126
|
+
{ role: "user", content: "first" },
|
|
127
|
+
{ role: "assistant", content: "reply" },
|
|
128
|
+
{ role: "user", content: "" }
|
|
129
|
+
]
|
|
130
|
+
});
|
|
131
|
+
const outbound = (await request?.json());
|
|
132
|
+
assert.deepEqual(outbound.messages.map((message) => message.role), ["user", "assistant", "user"]);
|
|
133
|
+
assert.equal(outbound.messages[2]?.content[0]?.type, "text");
|
|
134
|
+
assert.ok(String(outbound.messages[2]?.content[0]?.text).trim().length > 0);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
globalThis.fetch = original;
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
test("Anthropic egress keeps tool calls on assistant turns without text", async () => {
|
|
141
|
+
const original = globalThis.fetch;
|
|
142
|
+
let request;
|
|
143
|
+
globalThis.fetch = async (input, init) => {
|
|
144
|
+
request = new Request(input, init);
|
|
145
|
+
return Response.json({
|
|
146
|
+
id: "msg_tools",
|
|
147
|
+
content: [{ type: "text", text: "ok" }],
|
|
148
|
+
usage: { input_tokens: 1, output_tokens: 1 }
|
|
149
|
+
});
|
|
150
|
+
};
|
|
151
|
+
try {
|
|
152
|
+
const backend = new AnthropicBackend({
|
|
153
|
+
baseUrl: "https://api.anthropic.test/v1",
|
|
154
|
+
apiKey: "secret",
|
|
155
|
+
defaultModel: "claude-test"
|
|
156
|
+
});
|
|
157
|
+
await backend.chat({
|
|
158
|
+
messages: [
|
|
159
|
+
{ role: "user", content: "read a.ts" },
|
|
160
|
+
{
|
|
161
|
+
role: "assistant",
|
|
162
|
+
content: null,
|
|
163
|
+
tool_calls: [
|
|
164
|
+
{ id: "call_1", function: { name: "read", arguments: "{\"path\":\"a.ts\"}" } }
|
|
165
|
+
]
|
|
166
|
+
},
|
|
167
|
+
{ role: "tool", tool_call_id: "call_1", content: "" }
|
|
168
|
+
]
|
|
169
|
+
});
|
|
170
|
+
const outbound = (await request?.json());
|
|
171
|
+
assert.deepEqual(outbound.messages.map((message) => message.content[0]?.type), ["text", "tool_use", "tool_result"]);
|
|
172
|
+
}
|
|
173
|
+
finally {
|
|
174
|
+
globalThis.fetch = original;
|
|
175
|
+
}
|
|
176
|
+
});
|
|
61
177
|
test("Anthropic egress preserves native thinking controls, signed history, and buffered blocks", async () => {
|
|
62
178
|
const original = globalThis.fetch;
|
|
63
179
|
let request;
|
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.10",
|
|
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-
|
|
33
|
-
"@velum-labs/routekit-contracts": "0.9.
|
|
30
|
+
"@velum-labs/routekit-tracing": "0.9.10",
|
|
31
|
+
"@velum-labs/routekit-runtime": "0.9.10",
|
|
32
|
+
"@velum-labs/routekit-registry": "0.9.10",
|
|
33
|
+
"@velum-labs/routekit-contracts": "0.9.10"
|
|
34
34
|
},
|
|
35
35
|
"keywords": [
|
|
36
36
|
"routekit",
|