From 4fe255347aceb1b04d4da9b9ed6e3a4fc8afcbef Mon Sep 17 00:00:00 2001 From: iamsrishanth <147091568+iamsrishanth@users.noreply.github.com> Date: Tue, 24 Mar 2026 15:38:25 +0530 Subject: [PATCH] feat: Add Anthropic Messages API compatibility layer (#2704) * feat: add Anthropic Messages API compatibility layer Add a new endpoint at /puterai/anthropic/v1/messages that implements the Anthropic Messages API wire format, allowing clients using the Anthropic SDK to point directly at Puter. - New router translates between Anthropic format and Puter's internal svcAiChat.complete() pipeline - Supports non-streaming, SSE streaming (proper Anthropic event sequence), and tool use round-trips - Translates system field, tool definitions (input_schema -> parameters), and tool_result content blocks - Integration tests covering non-streaming, streaming, tool use, Anthropic SDK compatibility, and system parameter Closes #2554 * Fix authentication middleware --------- Co-authored-by: ProgrammerIn-wonderland <30693865+ProgrammerIn-wonderland@users.noreply.github.com> --- .../src/routers/puterai/anthropic/messages.js | 442 ++++++++++++++++++ src/backend/src/services/ChatAPIService.js | 17 +- .../ai_anthropic_messages.test.ts | 244 ++++++++++ 3 files changed, 695 insertions(+), 8 deletions(-) create mode 100644 src/backend/src/routers/puterai/anthropic/messages.js create mode 100644 tests/puterJsApiTests/ai_anthropic_messages.test.ts diff --git a/src/backend/src/routers/puterai/anthropic/messages.js b/src/backend/src/routers/puterai/anthropic/messages.js new file mode 100644 index 000000000..7c94dc565 --- /dev/null +++ b/src/backend/src/routers/puterai/anthropic/messages.js @@ -0,0 +1,442 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ +'use strict'; + +const crypto = require('node:crypto'); +const APIError = require('../../../api/APIError.js'); +const eggspress = require('../../../api/eggspress.js'); +const { TypedValue } = require('../../../services/drivers/meta/Runtime.js'); +const { Context } = require('../../../util/context.js'); +const auth2 = require('../../../middleware/auth2.js'); + +const DEFAULT_PROVIDER = 'claude'; + +/** + * Translate Anthropic-style tool definitions to the OpenAI/Puter internal + * format so that `svcAiChat.complete()` handles them uniformly. + */ +const normalizeTools = (tools) => { + if ( !Array.isArray(tools) || tools.length === 0 ) return undefined; + return tools.map((t) => { + // Already in OpenAI format (e.g. from passthrough) + if ( t.type === 'function' && t.function ) return t; + // Anthropic format: { name, description, input_schema } + return { + type: 'function', + function: { + name: t.name, + description: t.description || '', + parameters: t.input_schema || { type: 'object', properties: {} }, + }, + }; + }); +}; + +/** + * Extract plain text from a Puter/OpenAI-style message content field. + */ +const extractTextContent = (content) => { + if ( content === undefined || content === null ) return ''; + if ( typeof content === 'string' ) return content; + if ( Array.isArray(content) ) { + return content.map((part) => { + if ( typeof part === 'string' ) return part; + if ( part && typeof part.text === 'string' ) return part.text; + if ( part && typeof part.content === 'string' ) return part.content; + return ''; + }).join(''); + } + if ( typeof content === 'object' ) { + if ( typeof content.text === 'string' ) return content.text; + if ( typeof content.content === 'string' ) return content.content; + } + return ''; +}; + +/** + * Build an Anthropic-style usage object from internal usage data. + */ +const buildUsage = (usage) => { + return { + input_tokens: usage?.input_tokens ?? usage?.prompt_tokens ?? 0, + output_tokens: usage?.output_tokens ?? usage?.completion_tokens ?? 0, + }; +}; + +/** + * Extract tool_use blocks from an internal message result and return them + * as Anthropic content blocks. + */ +const extractToolUseBlocks = (message) => { + const blocks = []; + + // Check for OpenAI-style tool_calls on the message object + if ( message.tool_calls && Array.isArray(message.tool_calls) ) { + for ( const tc of message.tool_calls ) { + blocks.push({ + type: 'tool_use', + id: tc.id, + name: tc.function?.name ?? '', + input: typeof tc.function?.arguments === 'string' + ? (() => { + try { + return JSON.parse(tc.function.arguments); + } catch { + return {}; + } + })() + : (tc.function?.arguments ?? {}), + }); + } + } + + // Check for tool_use blocks inside array-style content + if ( Array.isArray(message.content) ) { + for ( const part of message.content ) { + if ( !part || typeof part !== 'object' ) continue; + if ( part.type === 'tool_use' ) { + blocks.push({ + type: 'tool_use', + id: part.id, + name: part.name, + input: typeof part.input === 'string' + ? (() => { + try { + return JSON.parse(part.input); + } catch { + return {}; + } + })() + : (part.input ?? {}), + }); + } + } + } + + return blocks; +}; + +/** + * Translate Anthropic-format messages into Puter/OpenAI-format messages. + * Specifically, this converts `tool_result` content blocks into `tool` role + * messages that Puter's internal pipeline expects. + */ +const normalizeMessages = (messages, system) => { + const result = []; + + // Inject system message at the start if supplied + if ( system ) { + if ( typeof system === 'string' ) { + result.push({ role: 'system', content: system }); + } else if ( Array.isArray(system) ) { + const text = system.map((s) => { + if ( typeof s === 'string' ) return s; + if ( s && typeof s.text === 'string' ) return s.text; + return ''; + }).join('\n'); + if ( text ) result.push({ role: 'system', content: text }); + } + } + + for ( const msg of messages ) { + // Anthropic places tool_result blocks inside user messages. + // Convert each to a separate `role: 'tool'` message. + if ( msg.role === 'user' && Array.isArray(msg.content) ) { + const toolResults = []; + const otherParts = []; + for ( const part of msg.content ) { + if ( part && part.type === 'tool_result' ) { + toolResults.push(part); + } else { + otherParts.push(part); + } + } + + // Push non-tool content first (if any) + if ( otherParts.length > 0 ) { + result.push({ role: 'user', content: otherParts }); + } + + // Convert each tool_result to a `tool` message + for ( const tr of toolResults ) { + let contentStr = ''; + if ( typeof tr.content === 'string' ) { + contentStr = tr.content; + } else if ( Array.isArray(tr.content) ) { + contentStr = tr.content.map((p) => { + if ( typeof p === 'string' ) return p; + if ( p && typeof p.text === 'string' ) return p.text; + return ''; + }).join(''); + } + result.push({ + role: 'tool', + tool_call_id: tr.tool_use_id, + content: contentStr, + }); + } + + // If the message was entirely tool_results, we already handled it + if ( otherParts.length === 0 && toolResults.length > 0 ) continue; + if ( toolResults.length > 0 ) continue; // already pushed otherParts above + } + + result.push(msg); + } + + return result; +}; + +const svc_web = Context.get('services').get('web-server'); +svc_web.allow_undefined_origin(/^\/puterai\/anthropic\/v1\/messages(\/.*)?$/); + +module.exports = eggspress('/anthropic/v1/messages', { + json: true, + jsonCanBeLarge: true, + allowedMethods: ['POST'], + mw: [(req, _res, next) => { + if ( !req.headers.authorization && req.headers['x-api-key'] ) { + req.headers.authorization = `Bearer ${req.headers['x-api-key']}`; + } + next(); + }, auth2], +}, async (req, res) => { + // We don't allow apps + if ( Context.get('actor').type.app ) { + throw APIError.create('permission_denied'); + } + + const body = req.body || {}; + const stream = !!body.stream; + + if ( ! Array.isArray(body.messages) ) { + throw APIError.create('field_invalid', { + key: 'messages', + expected: 'an array of chat messages', + got: typeof body.messages, + }); + } + + const ctx = Context.get(); + const services = ctx.get('services'); + const svcAiChat = services.get('ai-chat'); + + let model = body.model; + if ( ! model ) { + const providerName = body.provider || DEFAULT_PROVIDER; + const provider = svcAiChat.getProvider(providerName); + if ( ! provider ) { + throw APIError.create('field_missing', { key: 'model' }); + } + model = provider.getDefaultModel(); + } + + // Translate messages from Anthropic format to Puter internal format + const normalizedMessages = normalizeMessages(body.messages, body.system); + const tools = normalizeTools(body.tools); + + const completeArgs = { + messages: normalizedMessages, + model, + stream, + ...(tools ? { tools } : {}), + ...(body.temperature !== undefined ? { temperature: body.temperature } : {}), + ...(body.max_tokens !== undefined ? { max_tokens: body.max_tokens } : {}), + ...(body.provider ? { provider: body.provider } : {}), + }; + + const messageId = `msg_${crypto.randomUUID().replace(/-/g, '')}`; + + const result = await svcAiChat.complete(completeArgs); + + // ================================================================ + // STREAMING RESPONSE — Anthropic SSE format + // ================================================================ + if ( stream ) { + if ( ! (result instanceof TypedValue) ) { + throw APIError.create('internal_error', { message: 'expected streaming response' }); + } + + res.setHeader('Content-Type', 'text/event-stream; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache, no-transform'); + res.setHeader('Connection', 'keep-alive'); + + const sendEvent = (eventType, data) => { + res.write(`event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`); + }; + + // message_start + sendEvent('message_start', { + type: 'message_start', + message: { + id: messageId, + type: 'message', + role: 'assistant', + content: [], + model, + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 }, + }, + }); + + let buffer = ''; + let usage = null; + let contentIndex = 0; + let blockOpen = false; + let sawToolCalls = false; + + const openTextBlock = () => { + if ( blockOpen ) return; + sendEvent('content_block_start', { + type: 'content_block_start', + index: contentIndex, + content_block: { type: 'text', text: '' }, + }); + blockOpen = true; + }; + + const closeBlock = () => { + if ( ! blockOpen ) return; + sendEvent('content_block_stop', { + type: 'content_block_stop', + index: contentIndex, + }); + blockOpen = false; + contentIndex++; + }; + + const streamValue = result.value; + streamValue.on('data', (chunk) => { + buffer += chunk.toString('utf8'); + let newlineIndex; + while ( (newlineIndex = buffer.indexOf('\n')) >= 0 ) { + const line = buffer.slice(0, newlineIndex).trim(); + buffer = buffer.slice(newlineIndex + 1); + if ( ! line ) continue; + let event; + try { + event = JSON.parse(line); + } catch { + continue; + } + + if ( event.type === 'text' && typeof event.text === 'string' ) { + openTextBlock(); + sendEvent('content_block_delta', { + type: 'content_block_delta', + index: contentIndex, + delta: { type: 'text_delta', text: event.text }, + }); + } + + if ( event.type === 'tool_use' ) { + sawToolCalls = true; + closeBlock(); // close any open text block first + sendEvent('content_block_start', { + type: 'content_block_start', + index: contentIndex, + content_block: { + type: 'tool_use', + id: event.id, + name: event.name, + input: {}, + }, + }); + blockOpen = true; + + // Emit the input as a single JSON delta + const inputStr = typeof event.input === 'string' + ? event.input + : JSON.stringify(event.input ?? {}); + sendEvent('content_block_delta', { + type: 'content_block_delta', + index: contentIndex, + delta: { type: 'input_json_delta', partial_json: inputStr }, + }); + closeBlock(); + } + + if ( event.type === 'usage' ) { + usage = event.usage; + } + } + }); + + streamValue.on('end', () => { + closeBlock(); + + const stopReason = sawToolCalls ? 'tool_use' : 'end_turn'; + const resolvedUsage = buildUsage(usage || {}); + + sendEvent('message_delta', { + type: 'message_delta', + delta: { stop_reason: stopReason, stop_sequence: null }, + usage: { output_tokens: resolvedUsage.output_tokens }, + }); + + sendEvent('message_stop', { type: 'message_stop' }); + res.end(); + }); + + streamValue.on('error', (err) => { + sendEvent('error', { + type: 'error', + error: { + type: 'api_error', + message: err?.message || 'stream error', + }, + }); + res.end(); + }); + + return; + } + + // ================================================================ + // NON-STREAMING RESPONSE — Anthropic message object + // ================================================================ + const message = result.message || {}; + const toolUseBlocks = extractToolUseBlocks(message); + const textContent = extractTextContent(message.content); + + const contentBlocks = []; + if ( textContent ) { + contentBlocks.push({ type: 'text', text: textContent }); + } + contentBlocks.push(...toolUseBlocks); + + // If there's no content at all, include an empty text block + if ( contentBlocks.length === 0 ) { + contentBlocks.push({ type: 'text', text: '' }); + } + + const stopReason = toolUseBlocks.length > 0 ? 'tool_use' : 'end_turn'; + + res.json({ + id: messageId, + type: 'message', + role: 'assistant', + content: contentBlocks, + model, + stop_reason: stopReason, + stop_sequence: null, + usage: buildUsage(result.usage), + }); +}); diff --git a/src/backend/src/services/ChatAPIService.js b/src/backend/src/services/ChatAPIService.js index e92968d6b..106f8d729 100644 --- a/src/backend/src/services/ChatAPIService.js +++ b/src/backend/src/services/ChatAPIService.js @@ -40,7 +40,7 @@ class ChatAPIService extends BaseService { * @param {Express} options.app Express application instance to install routes on * @returns {Promise} */ - async '__on_install.routes' (_, { app }) { + async '__on_install.routes'(_, { app }) { // Create a router for chat API endpoints const router = (() => { const require = this.require; @@ -61,10 +61,11 @@ class ChatAPIService extends BaseService { * @param {express.Router} options.router Express router to install endpoints on * @private */ - install_chat_endpoints_ ({ router }) { + install_chat_endpoints_({ router }) { const Endpoint = this.require('Endpoint'); router.use(require('../routers/puterai/openai/completions')); router.use(require('../routers/puterai/openai/chat_completions')); + router.use(require('../routers/puterai/anthropic/messages')); // Endpoint to list available AI chat models Endpoint({ route: '/chat/models', @@ -81,7 +82,7 @@ class ChatAPIService extends BaseService { // Return the list of models res.json({ models: models.filter(e => !['costly', 'fake', 'abuse', 'model-fallback-test-1'].includes(e)) }); - } catch ( error ) { + } catch (error) { this.log.error('Error fetching models:', error); throw APIError.create('internal_server_error'); } @@ -104,7 +105,7 @@ class ChatAPIService extends BaseService { // Return the detailed list of models res.json({ models: models.filter((e) => !['costly', 'fake', 'abuse', 'model-fallback-test-1'].includes(e.id)) }); - } catch ( error ) { + } catch (error) { this.log.error('Error fetching model details:', error); throw APIError.create('internal_server_error'); } @@ -125,7 +126,7 @@ class ChatAPIService extends BaseService { }); // Return the list of models res.json({ models }); - } catch ( error ) { + } catch (error) { this.log.error('Error fetching image models:', error); throw APIError.create('internal_server_error'); } @@ -146,7 +147,7 @@ class ChatAPIService extends BaseService { }); // Return the detailed list of models res.json({ models }); - } catch ( error ) { + } catch (error) { this.log.error('Error fetching image model details:', error); throw APIError.create('internal_server_error'); } @@ -164,7 +165,7 @@ class ChatAPIService extends BaseService { return svc_video.models(); }); res.json({ models }); - } catch ( error ) { + } catch (error) { this.log.error('Error fetching video model details:', error); throw APIError.create('internal_server_error'); } @@ -182,7 +183,7 @@ class ChatAPIService extends BaseService { return svc_video.list(); }); res.json({ models }); - } catch ( error ) { + } catch (error) { this.log.error('Error fetching video models:', error); throw APIError.create('internal_server_error'); } diff --git a/tests/puterJsApiTests/ai_anthropic_messages.test.ts b/tests/puterJsApiTests/ai_anthropic_messages.test.ts new file mode 100644 index 000000000..9bf3f7dc4 --- /dev/null +++ b/tests/puterJsApiTests/ai_anthropic_messages.test.ts @@ -0,0 +1,244 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as yaml from 'yaml'; +import Anthropic from '@anthropic-ai/sdk'; + +interface ClientConfig { + api_url: string; + auth_token?: string; + do_expensive_ai_tests?: boolean; +} + +const loadConfig = (): ClientConfig => { + const envApiUrl = process.env.PUTER_API_URL; + const envAuthToken = process.env.PUTER_AUTH_TOKEN; + if (envApiUrl) { + return { + api_url: envApiUrl, + auth_token: envAuthToken, + do_expensive_ai_tests: process.env.PUTER_DO_EXPENSIVE_AI_TESTS === 'true', + }; + } + + const configPath = path.join(__dirname, '../client-config.yaml'); + if (!fs.existsSync(configPath)) { + throw new Error('Missing client-config.yaml. Create tests/client-config.yaml ' + + 'or set PUTER_API_URL and PUTER_AUTH_TOKEN.'); + } + return yaml.parse(fs.readFileSync(configPath, 'utf8')) as ClientConfig; +}; + +const buildHeaders = (authToken?: string) => { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (authToken) { + headers['Authorization'] = `Bearer ${authToken}`; + } + return headers; +}; + +const postMessages = async (body: unknown) => { + const config = loadConfig(); + const url = `${config.api_url}/puterai/anthropic/v1/messages`; + const response = await fetch(url, { + method: 'POST', + headers: buildHeaders(config.auth_token), + body: JSON.stringify(body), + }); + return { response, config }; +}; + +describe('Puter Anthropic-Compatible Messages API', () => { + it('returns a well-formed non-streaming message', async () => { + const config = loadConfig(); + if (!config.do_expensive_ai_tests) return; + + const { response } = await postMessages({ + model: 'claude-haiku-4-5', + max_tokens: 256, + messages: [ + { role: 'user', content: 'Say hello in exactly three words.' }, + ], + }); + + expect(response.status).toBe(200); + const json = await response.json() as any; + expect(json.type).toBe('message'); + expect(json.role).toBe('assistant'); + expect(json.id).toMatch(/^msg_/); + expect(Array.isArray(json.content)).toBe(true); + expect(json.content.length).toBeGreaterThan(0); + expect(json.content[0].type).toBe('text'); + expect(typeof json.content[0].text).toBe('string'); + expect(json.stop_reason).toBe('end_turn'); + expect(typeof json.usage?.input_tokens).toBe('number'); + expect(typeof json.usage?.output_tokens).toBe('number'); + }, 20000); + + it('streams and returns well-formed SSE events', async () => { + const config = loadConfig(); + if (!config.do_expensive_ai_tests) return; + + const { response } = await postMessages({ + model: 'claude-haiku-4-5', + max_tokens: 256, + stream: true, + messages: [ + { role: 'user', content: 'What is 2 + 2?' }, + ], + }); + + expect(response.status).toBe(200); + const reader = response.body?.getReader(); + expect(reader).toBeTruthy(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ''; + const events: string[] = []; + + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buffer += decoder.decode(value, { stream: true }); + + let idx; + while ((idx = buffer.indexOf('\n\n')) >= 0) { + const block = buffer.slice(0, idx); + buffer = buffer.slice(idx + 2); + const eventMatch = block.match(/^event: (\S+)/m); + if (eventMatch) events.push(eventMatch[1]); + } + } + + expect(events).toContain('message_start'); + expect(events).toContain('content_block_start'); + expect(events).toContain('content_block_delta'); + expect(events).toContain('content_block_stop'); + expect(events).toContain('message_delta'); + expect(events).toContain('message_stop'); + }, 20000); + + it('handles tool use round-trip (non-streaming)', async () => { + const config = loadConfig(); + if (!config.do_expensive_ai_tests) return; + + const tools = [ + { + name: 'calculate', + description: 'Perform a mathematical calculation', + input_schema: { + type: 'object', + properties: { + expression: { + type: 'string', + description: 'Mathematical expression to evaluate', + }, + }, + required: ['expression'], + }, + }, + ]; + + // First turn: ask the model to use the tool + const { response: firstResponse } = await postMessages({ + model: 'claude-haiku-4-5', + max_tokens: 1024, + messages: [ + { role: 'user', content: 'Use the calculate tool to compute 2 + 2.' }, + ], + tools, + }); + + expect(firstResponse.status).toBe(200); + const firstJson = await firstResponse.json() as any; + + const toolUseBlocks = (firstJson.content || []).filter( + (b: any) => b.type === 'tool_use', + ); + + if (toolUseBlocks.length === 0) { + // Model did not call tools — inconclusive, skip + return; + } + + expect(toolUseBlocks[0].name).toBe('calculate'); + expect(toolUseBlocks[0].id).toBeTruthy(); + + // Second turn: provide tool result and get final answer + const { response: secondResponse } = await postMessages({ + model: 'claude-haiku-4-5', + max_tokens: 1024, + messages: [ + { role: 'user', content: 'Use the calculate tool to compute 2 + 2.' }, + { role: 'assistant', content: firstJson.content }, + { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseBlocks[0].id, + content: JSON.stringify({ expression: '2 + 2', result: 4 }), + }, + ], + }, + ], + tools, + }); + + expect(secondResponse.status).toBe(200); + const secondJson = await secondResponse.json() as any; + expect(secondJson.type).toBe('message'); + const textBlocks = (secondJson.content || []).filter( + (b: any) => b.type === 'text', + ); + expect(textBlocks.length).toBeGreaterThan(0); + expect(textBlocks[0].text).toBeTruthy(); + }, 30000); + + it('works with the Anthropic SDK', async () => { + const config = loadConfig(); + if (!config.do_expensive_ai_tests) return; + const apiKey = config.auth_token; + if (!apiKey) throw new Error('Missing auth token for Anthropic SDK test'); + + const client = new Anthropic({ + apiKey, + baseURL: `${config.api_url}/puterai/anthropic/v1`, + }); + + const message = await client.messages.create({ + model: 'claude-haiku-4-5', + max_tokens: 256, + messages: [ + { role: 'user', content: 'Say hello.' }, + ], + }); + + expect(message.type).toBe('message'); + expect(message.role).toBe('assistant'); + expect(message.content.length).toBeGreaterThan(0); + expect(message.content[0].type).toBe('text'); + }, 20000); + + it('accepts a system parameter', async () => { + const config = loadConfig(); + if (!config.do_expensive_ai_tests) return; + + const { response } = await postMessages({ + model: 'claude-haiku-4-5', + max_tokens: 256, + system: 'You are a pirate. Always respond in pirate speak.', + messages: [ + { role: 'user', content: 'Say hello.' }, + ], + }); + + expect(response.status).toBe(200); + const json = await response.json() as any; + expect(json.type).toBe('message'); + expect(json.content[0].text).toBeTruthy(); + }, 20000); +});