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);
+});