Skip to content

feat: Add Anthropic Messages API compatibility layer#2704

Merged
ProgrammerIn-wonderland merged 3 commits intoHeyPuter:mainfrom
iamsrishanth:feat/anthropic-api-compatibility
Mar 24, 2026
Merged

feat: Add Anthropic Messages API compatibility layer#2704
ProgrammerIn-wonderland merged 3 commits intoHeyPuter:mainfrom
iamsrishanth:feat/anthropic-api-compatibility

Conversation

@iamsrishanth
Copy link
Copy Markdown
Contributor

Summary

Adds an Anthropic Messages API compatibility endpoint at /puterai/anthropic/v1/messages, allowing clients using the official @anthropic-ai/sdk (or any Anthropic-format HTTP client) to point directly at Puter.

This mirrors the existing OpenAI compatibility layer at /puterai/openai/v1/....

Changes

New files

  • src/backend/src/routers/puterai/anthropic/messages.js Router that translates between Anthropic wire format and Puter's internal svcAiChat.complete() pipeline
  • tests/puterJsApiTests/ai_anthropic_messages.test.ts Integration tests (5 test cases)

Modified files

  • src/backend/src/services/ChatAPIService.js One line added to register the new router

Features

  • Non-streaming responses Returns Anthropic message objects with content blocks
  • SSE streaming Proper Anthropic event sequence (message_start content_block_start content_block_delta content_block_stop message_delta message_stop)
  • Tool use Translates Anthropic tool definitions (input_schema) and tool_result content blocks
  • System parameter Supports Anthropic's top-level system field

Testing

Integration tests gated behind do_expensive_ai_tests covering:

  1. Non-streaming basic response shape
  2. Streaming SSE event sequence
  3. Tool use round-trip
  4. Anthropic SDK compatibility
  5. System parameter handling

Closes #2554

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 HeyPuter#2554
@CLAassistant
Copy link
Copy Markdown

CLAassistant commented Mar 20, 2026

CLA assistant check
All committers have signed the CLA.

@ProgrammerIn-wonderland
Copy link
Copy Markdown
Collaborator

I wrote my own test program to test this PR and seem to get this error, even though I confirmed my authtoken is correct

node ./test-anthropic-messages-app.js 
Base URL: http://api.puter.localhost:4100/puterai/anthropic
Model: gpt-5-nano

=== Basic Message ===

Test app failed.
AuthenticationError: 401 Missing authentication token.
    at APIError.generate (file:///home/alice/git/puter/node_modules/@anthropic-ai/sdk/core/error.mjs:40:20)
    at Anthropic.makeStatusError (file:///home/alice/git/puter/node_modules/@anthropic-ai/sdk/client.mjs:152:32)
    at Anthropic.makeRequest (file:///home/alice/git/puter/node_modules/@anthropic-ai/sdk/client.mjs:306:30)
    at process.processTicksAndRejections (node:internal/process/task_queues:103:5)
    at async runBasicMessage (file:///home/alice/git/puter/test-anthropic-messages-app.js:29:21)
    at async main (file:///home/alice/git/puter/test-anthropic-messages-app.js:194:5) {
  status: 401,
  headers: Headers {
    'x-content-type-options': 'nosniff',
    'strict-transport-security': 'max-age=15552000; includeSubDomains',
    'x-download-options': 'noopen',
    'x-permitted-cross-domain-policies': 'none',
    'x-xss-protection': '0',
    'access-control-allow-origin': '*',
    'access-control-allow-methods': 'GET, POST, OPTIONS, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK',
    'access-control-allow-headers': 'Origin, X-Requested-With, Content-Type, Accept, Authorization, sentry-trace, baggage, Depth, Destination, Overwrite, If, Lock-Token, DAV, stripe-signature',
    'cross-origin-resource-policy': 'cross-origin',
    'content-type': 'application/json; charset=utf-8',
    'content-length': '66',
    etag: 'W/"42-5aHzcjwtZEdRtZDkwR4bdqxuSlk"',
    vary: 'Accept-Encoding',
    date: 'Tue, 24 Mar 2026 09:49:35 GMT',
    connection: 'keep-alive',
    'keep-alive': 'timeout=5'
  },
  requestID: null,
  error: { message: 'Missing authentication token.', code: 'token_missing' }
}

@ProgrammerIn-wonderland
Copy link
Copy Markdown
Collaborator

Test program

#!/usr/bin/env node

import Anthropic from '@anthropic-ai/sdk';

const required = (name) => {
    const value = process.env[name];
    if ( ! value ) {
        throw new Error(`Missing ${name}`);
    }
    return value;
};

const apiUrl = required('PUTER_API_URL').replace(/\/$/, '');
const authToken = required('PUTER_AUTH_TOKEN');
const model = process.env.PUTER_MODEL || 'claude-haiku-4-5-20251001';

const client = new Anthropic({
    apiKey: authToken,
    baseURL: `${apiUrl}/puterai/anthropic`,
});

const logSection = (title) => {
    console.log(`\n=== ${title} ===`);
};

const runBasicMessage = async () => {
    logSection('Basic Message');

    const message = await client.messages.create({
        model,
        max_tokens: 1024,
        messages: [
            {
                role: 'user',
                content: 'Reply with one short sentence confirming the Anthropic compatibility layer works.',
            },
        ],
    });

    const text = message.content
        .filter((block) => block.type === 'text')
        .map((block) => block.text)
        .join('');

    console.log('id:', message.id);
    console.log('model:', message.model);
    console.log('stop_reason:', message.stop_reason);
    console.log('text:', text.trim());
    console.log('usage:', message.usage);
};

const runToolRoundTrip = async () => {
    logSection('Tool Round Trip');

    const tools = [
        {
            name: 'calculate',
            description: 'Evaluate a basic arithmetic expression.',
            input_schema: {
                type: 'object',
                properties: {
                    expression: {
                        type: 'string',
                        description: 'The arithmetic expression to evaluate.',
                    },
                },
                required: ['expression'],
                additionalProperties: false,
            },
        },
    ];

    const first = await client.messages.create({
        model,
        max_tokens: 1024,
        messages: [
            {
                role: 'user',
                content: 'Use the calculate tool to compute 12 * 7, then answer with the result.',
            },
        ],
        tools,
    });

    const toolUse = first.content.find((block) => block.type === 'tool_use');
    const firstText = first.content
        .filter((block) => block.type === 'text')
        .map((block) => block.text)
        .join('');

    console.log('first message id:', first.id);
    console.log('first stop_reason:', first.stop_reason);
    console.log('first text:', JSON.stringify(firstText));

    if ( ! toolUse || toolUse.type !== 'tool_use' ) {
        console.log('No tool use block was returned.');
        return;
    }

    const expression = typeof toolUse.input?.expression === 'string'
        ? toolUse.input.expression
        : '';
    const result = Function(`"use strict"; return (${expression});`)();

    console.log('tool use:', {
        id: toolUse.id,
        name: toolUse.name,
        input: toolUse.input,
        result,
    });

    const followup = await client.messages.create({
        model,
        max_tokens: 1024,
        tools,
        messages: [
            {
                role: 'user',
                content: 'Use the calculate tool to compute 12 * 7, then answer with the result.',
            },
            {
                role: 'assistant',
                content: first.content,
            },
            {
                role: 'user',
                content: [
                    {
                        type: 'tool_result',
                        tool_use_id: toolUse.id,
                        content: JSON.stringify({
                            expression,
                            result,
                        }),
                    },
                ],
            },
        ],
    });

    const finalText = followup.content
        .filter((block) => block.type === 'text')
        .map((block) => block.text)
        .join('');

    console.log('final message id:', followup.id);
    console.log('final stop_reason:', followup.stop_reason);
    console.log('final text:', finalText.trim());
    console.log('final usage:', followup.usage);
};

const runStreamingMessage = async () => {
    logSection('Streaming Message');

    const stream = await client.messages.create({
        model,
        max_tokens: 1024,
        stream: true,
        messages: [
            {
                role: 'user',
                content: 'Output exactly five lines: 1, 2, 3, 4, 5. Do not stop early.',
            },
        ],
    });

    let sawText = false;

    for await ( const event of stream ) {
        if ( event.type === 'content_block_delta' && event.delta.type === 'text_delta' ) {
            process.stdout.write(event.delta.text);
            sawText = true;
        }

        if ( event.type === 'content_block_delta' && event.delta.type === 'input_json_delta' ) {
            process.stdout.write(`\n[tool json delta] ${event.delta.partial_json}\n`);
        }

        if ( event.type === 'message_delta' ) {
            console.log('\nstop_reason:', event.delta.stop_reason);
            console.log('usage:', event.usage);
        }
    }

    if ( ! sawText ) {
        console.log('\nNo streamed text content was returned.');
    }
};

const main = async () => {
    console.log('Base URL:', `${apiUrl}/puterai/anthropic`);
    console.log('Model:', model);

    await runBasicMessage();
    await runToolRoundTrip();
    await runStreamingMessage();
};

main().catch((error) => {
    console.error('\nTest app failed.');
    console.error(error);
    process.exitCode = 1;
});

@ProgrammerIn-wonderland
Copy link
Copy Markdown
Collaborator

Primarily I think it is becaues anthropic does not expect API key to be in "Authorization" but rather, their own header. But you're using auth2, which expects Authorization
image

@ProgrammerIn-wonderland
Copy link
Copy Markdown
Collaborator

I modified the eggspress declaration to be

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],

and that seemed adequate

@ProgrammerIn-wonderland
Copy link
Copy Markdown
Collaborator

after that, this PR seems to work fine. Good work! Tool calls, streaming, and non anthropic models seem to work as intended

@ProgrammerIn-wonderland ProgrammerIn-wonderland merged commit 4fe2553 into HeyPuter:main Mar 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add Anthropic API compatibility

3 participants