@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.8",
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-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"
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",