From f786aaa2c7aad2f432a860beadfca41e479c97fe Mon Sep 17 00:00:00 2001 From: mikemolinet Date: Wed, 15 Apr 2026 15:33:46 -0700 Subject: [PATCH] fix: accept Anthropic system field as content-block array The Anthropic Messages API accepts the top-level `system` field as either a string OR an array of content blocks (per https://docs.anthropic.com/en/api/messages). The /v1/messages handler at index.js:1044-1047 only checks `typeof body.system === "string"` and silently drops the array form. Clients that follow the spec see their system prompt ignored by the proxy. Add and export a `normalizeAnthropicSystem` helper that accepts either form: for the array form, concatenates `type: "text"` content blocks (skipping falsy entries, non-text types, and non-string texts); returns null when no usable text is present so the call site can skip adding an empty system message. Use it at the call site in place of the inline string check. Adds 3 regression tests in index.test.js covering: - array-form system reaches buildSystemPrompt (discriminating) - multi-block text arrays are concatenated - helper edge cases (null/undefined, empty strings, non-text blocks, non-string/non-array inputs) Closes #46 --- README.md | 2 +- index.js | 28 ++++++++++--- index.test.js | 109 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2077d16..099ca1a 100644 --- a/README.md +++ b/README.md @@ -316,7 +316,7 @@ OpenAI Chat Completions. Required fields: `model`, `messages`. Optional: `stream OpenAI Responses API. Required fields: `model`, `input`. Optional: `instructions`, `stream`, `max_output_tokens`. ### POST /v1/messages -Anthropic Messages API. Required fields: `model`, `messages`. Optional: `system`, `max_tokens`, `stream`. +Anthropic Messages API. Required fields: `model`, `messages`. Optional: `system` (string or array of `{type: "text", text: string}` content blocks), `max_tokens`, `stream`. Errors are returned in Anthropic format: `{ "type": "error", "error": { "type": "...", "message": "..." } }`. diff --git a/index.js b/index.js index e3532e9..9701fef 100644 --- a/index.js +++ b/index.js @@ -592,6 +592,22 @@ export function normalizeAnthropicMessages(messages) { .filter((message) => message.content.length > 0) } +export function normalizeAnthropicSystem(system) { + if (typeof system === "string") { + const trimmed = system.trim() + return trimmed || null + } + if (Array.isArray(system)) { + const text = system + .filter((block) => block && block.type === "text" && typeof block.text === "string") + .map((block) => block.text.trim()) + .filter(Boolean) + .join("\n\n") + return text || null + } + return null +} + export function mapFinishReasonToAnthropic(finish) { if (!finish) return "end_turn" if (finish.includes("length")) return "max_tokens" @@ -1040,11 +1056,13 @@ export function createProxyFetchHandler(client) { return anthropicBadRequest("No text content was found in the supplied messages.", 400, request) } - // Prepend Anthropic top-level system string as a system message so buildSystemPrompt picks it up. - const allMessages = - typeof body.system === "string" && body.system.trim() - ? [{ role: "system", content: body.system.trim() }, ...messages] - : messages + // Prepend Anthropic top-level `system` (string or array-of-content-blocks, + // per the Messages API spec) as a system message so buildSystemPrompt + // picks it up. + const systemText = normalizeAnthropicSystem(body.system) + const allMessages = systemText + ? [{ role: "system", content: systemText }, ...messages] + : messages const system = buildSystemPrompt(allMessages, { temperature: body.temperature, diff --git a/index.test.js b/index.test.js index f663ea0..f07c01d 100644 --- a/index.test.js +++ b/index.test.js @@ -14,6 +14,7 @@ import { resolveModel, normalizeAnthropicMessages, mapFinishReasonToAnthropic, + normalizeAnthropicSystem, normalizeGeminiContents, extractGeminiSystemInstruction, mapFinishReasonToGemini, @@ -1388,6 +1389,114 @@ test("POST /v1/messages system string is included in prompt", async () => { assert.ok(capturedSystem?.includes("You are a pirate.")) }) +test("POST /v1/messages system as content-block array is included in prompt", async () => { + let capturedSystem = null + const client = { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [{ id: "anthropic", models: { "claude-3-5-sonnet": { id: "claude-3-5-sonnet" } } }], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-ant-sys-arr" } }), + prompt: async ({ body }) => { + capturedSystem = body.system + return { + data: { + parts: [{ type: "text", text: "ok" }], + info: { tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "end_turn" }, + }, + } + }, + }, + } + + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-3-5-sonnet", + system: [{ type: "text", text: "You are a pirate." }], + messages: [{ role: "user", content: "Hello." }], + }), + }) + + await handler(request) + assert.ok(capturedSystem?.includes("You are a pirate.")) +}) + +test("POST /v1/messages system as multi-block array concatenates text", async () => { + let capturedSystem = null + const client = { + app: { log: async () => {} }, + tool: { ids: async () => ({ data: [] }) }, + config: { + providers: async () => ({ + data: { + providers: [{ id: "anthropic", models: { "claude-3-5-sonnet": { id: "claude-3-5-sonnet" } } }], + }, + }), + }, + session: { + create: async () => ({ data: { id: "sess-ant-sys-multi" } }), + prompt: async ({ body }) => { + capturedSystem = body.system + return { + data: { + parts: [{ type: "text", text: "ok" }], + info: { tokens: { input: 1, output: 1, reasoning: 0, cache: { read: 0, write: 0 } }, finish: "end_turn" }, + }, + } + }, + }, + } + + const handler = createProxyFetchHandler(client) + const request = new Request("http://127.0.0.1:4010/v1/messages", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "anthropic/claude-3-5-sonnet", + system: [ + { type: "text", text: "Line one." }, + { type: "text", text: "Line two." }, + ], + messages: [{ role: "user", content: "Hello." }], + }), + }) + + await handler(request) + assert.ok(capturedSystem?.includes("Line one.")) + assert.ok(capturedSystem?.includes("Line two.")) +}) + +test("normalizeAnthropicSystem handles string, array, and edge cases", () => { + assert.equal(normalizeAnthropicSystem("hello"), "hello") + assert.equal(normalizeAnthropicSystem(" hi "), "hi") + assert.equal(normalizeAnthropicSystem(""), null) + assert.equal(normalizeAnthropicSystem(" "), null) + assert.equal(normalizeAnthropicSystem([{ type: "text", text: "a" }]), "a") + assert.equal( + normalizeAnthropicSystem([ + { type: "text", text: "a" }, + { type: "text", text: "b" }, + ]), + "a\n\nb", + ) + assert.equal(normalizeAnthropicSystem([{ type: "image", source: {} }]), null) + assert.equal(normalizeAnthropicSystem([]), null) + assert.equal(normalizeAnthropicSystem([{ type: "text", text: "" }]), null) + assert.equal(normalizeAnthropicSystem(undefined), null) + assert.equal(normalizeAnthropicSystem(null), null) + assert.equal(normalizeAnthropicSystem(42), null) + assert.equal(normalizeAnthropicSystem([null, { type: "text", text: "x" }]), "x") +}) + test("POST /v1/messages missing model returns Anthropic error format", async () => { const handler = createProxyFetchHandler(createAnthropicClient()) const request = new Request("http://127.0.0.1:4010/v1/messages", {