Skip to content

server: Anthropic-compatible Messages API (/v1/messages) (#325) - #326

Open
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:anthropic-messages-api
Open

server: Anthropic-compatible Messages API (/v1/messages) (#325)#326
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:anthropic-messages-api

Conversation

@jamesburton

Copy link
Copy Markdown

Summary

Adds an Anthropic-compatible Messages API layer to DotLLM.Server, served alongside the existing OpenAI-compatible endpoints (Step 34). Clients and SDKs written for the Anthropic Messages API (the anthropic Python/TS SDKs, anything targeting POST /v1/messages) can now point at a dotLLM server unchanged.

This is purely additive — the engine, tokenizer, chat-template, sampler and tool-calling pipeline are reused verbatim. Only a new HTTP DTO + translation surface is introduced; no existing endpoint, engine, or sampling behaviour changes.

Closes #325.

Endpoints

  • POST /v1/messages — non-streaming (JSON) and streaming (event-based SSE: message_start, content_block_start/_delta/_stop, message_delta, message_stop, ping).
  • POST /v1/messages/count_tokens — returns { "input_tokens": N }.

Translation (AnthropicConverter)

  • Top-level system (string or text-block array) → leading system message.
  • Message content as a string or a content-block array; text / tool_use / tool_result blocks mapped to engine ChatMessage / ToolCall and tool-role messages keyed by tool_use_id.
  • tools[].input_schemaToolDefinition; tool_choice auto/any/none/tool → engine ToolChoice.
  • FinishReasonstop_reason (end_turn / max_tokens / stop_sequence / tool_use).
  • Anthropic error envelope ({"type":"error","error":{...}}); all DTOs registered in the source-gen ServerJsonContext (AOT-clean, no reflection).

Tests & Docs

  • 26 unit tests (AnthropicConverterTests): message flattening (string/blocks/system), tool_choice, stop_reason mapping, tool_use block emission, request validation, and response/error serialization shape. Full server unit suite green.
  • New docs/ANTHROPIC_API.md (full mapping + streaming sequence) and docs/SERVER.md / ROADMAP.md (Step 34b) / README.md / CLAUDE.md updates.

Notes / Limitations

  • Streaming tool calls are detected post-generation (matching the existing OpenAI streaming endpoint's post-hoc detection), so tool_use blocks are emitted after the text block closes rather than incrementally.
  • image / multimodal content blocks are not yet supported (no multimodal pipeline).
  • Same single-request serialization, validation, and prompt-caching semantics as the OpenAI endpoints apply.

🤖 Generated with Claude Code

Add an Anthropic Messages API layer to DotLLM.Server alongside the existing
OpenAI surface, so clients written for the `anthropic` SDKs can talk to dotLLM
unchanged. Purely additive — the engine, chat template, sampler and
tool-calling pipeline are reused verbatim; only the wire format differs.

Endpoints:
- POST /v1/messages — non-streaming (JSON) and streaming (named SSE events:
  message_start, content_block_start/_delta/_stop, message_delta, message_stop).
- POST /v1/messages/count_tokens — { "input_tokens": N }.

Translation (AnthropicConverter):
- Top-level system (string or text-block array) -> leading system message.
- String-or-block message content; text/tool_use/tool_result blocks mapped to
  ChatMessage/ToolCall and tool-role messages keyed by tool_use_id.
- tools/input_schema -> ToolDefinition; tool_choice auto/any/none/tool.
- FinishReason -> stop_reason (end_turn/max_tokens/stop_sequence/tool_use).
- Anthropic error envelope; AOT-clean source-gen DTOs in ServerJsonContext.

Tests: 26 unit tests (message flattening, tool_choice, stop_reason, tool_use
blocks, validation, response/error serialization shape).
Docs: docs/ANTHROPIC_API.md + SERVER.md/ROADMAP.md/README.md/CLAUDE.md sync.

Closes #325

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings June 15, 2026 13:17

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds an Anthropic-compatible Messages API surface to the server (request/response DTOs, converter, and endpoints), plus documentation and unit tests to validate the translation layer and JSON shapes.

Changes:

  • Introduces /v1/messages and /v1/messages/count_tokens endpoints with non-streaming JSON and streaming SSE event support.
  • Adds Anthropic Messages API DTOs and a converter to map Anthropic wire format to dotLLM engine types.
  • Updates JSON source-generation context and documentation; adds unit tests for converter/serialization/validation.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
tests/DotLLM.Tests.Unit/Server/AnthropicConverterTests.cs Adds unit tests for Anthropic conversion, validation, and serialization shapes.
src/DotLLM.Server/ServerJsonContext.cs Registers Anthropic DTOs/events for System.Text.Json source generation.
src/DotLLM.Server/Models/AnthropicMessagesModels.cs Introduces DTOs for Anthropic requests/responses/errors/streaming events.
src/DotLLM.Server/Endpoints/MessagesEndpoint.cs Implements Anthropic-compatible endpoints, streaming SSE, and request validation.
src/DotLLM.Server/EndpointExtensions.cs Wires the new endpoint mapping into the server.
src/DotLLM.Server/AnthropicConverter.cs Adds conversion logic between Anthropic DTOs and engine types.
docs/SERVER.md Documents the new endpoints at a high level and links to full Anthropic API docs.
docs/ROADMAP.md Marks Anthropic Messages API support as done and references docs.
docs/ANTHROPIC_API.md Adds detailed API documentation for request/response and streaming event sequence.
README.md Adds feature bullet + news entry about the new Anthropic-compatible API.
CLAUDE.md Adds a documentation index entry for the new Anthropic API doc.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +259 to +267
public static JsonElement ParseInput(string? arguments)
{
if (string.IsNullOrWhiteSpace(arguments))
return EmptyObject();
try
{
using var doc = JsonDocument.Parse(arguments);
return doc.RootElement.Clone();
}
Comment on lines +331 to +348
internal static string? ValidateRequest(AnthropicMessagesRequest request, bool requireMaxTokens)
{
if (request.Messages is null || request.Messages.Length == 0)
return "messages: at least one message is required";

if (request.Messages.Length > RequestValidator.MaxMessages)
return $"messages: array exceeds maximum of {RequestValidator.MaxMessages}";

if (requireMaxTokens)
{
if (!request.MaxTokens.HasValue)
return "max_tokens: field required";
if (request.MaxTokens.Value <= 0)
return "max_tokens: must be a positive integer";
}

return null;
}
Comment on lines +168 to +170
httpContext.Response.ContentType = "text/event-stream";
httpContext.Response.Headers.CacheControl = "no-cache";
httpContext.Response.Headers.Connection = "keep-alive";
Comment thread docs/ANTHROPIC_API.md
Comment on lines +41 to +44
"tool_choice": {"type": "auto"},
"stream": false,
"lora_adapter": "customer-support"
}
Comment thread src/DotLLM.Server/AnthropicConverter.cs Outdated
Comment on lines +274 to +278
private static JsonElement EmptyObject()
{
using var doc = JsonDocument.Parse("{}");
return doc.RootElement.Clone();
}
Comment on lines +156 to +166
private static async Task HandleStreamingAsync(
AnthropicMessagesRequest request,
TextGenerator generator,
ServerState state,
HttpContext httpContext,
string prompt,
DotLLM.Core.Configuration.InferenceOptions options,
string messageId, string modelId,
ToolDefinition[]? tools,
int promptTokenCount,
CancellationToken ct)
Review follow-ups on the /v1/messages surface:

- ParseInput: Anthropic requires tool_use.input to be an object, so a non-object
  JSON root (bare string/array/scalar from a model that ignored the schema) now
  collapses to {} instead of being emitted as an invalid wire shape.
- ParseInput/EmptyObject: share one cloned empty-object JsonElement instead of
  reparsing "{}" on every empty or invalid tool input.
- ValidateRequest: reject messages[].role other than user/assistant, and
  messages[].content that is neither a string nor an array of blocks. The
  converter passes role straight to the chat template, so an unchecked role let a
  caller inject a system turn mid-conversation; an unchecked content kind
  silently flattened to an empty message.
- Streaming: drop the `Connection: keep-alive` response header — it is a
  connection-specific header that is illegal over HTTP/2 and HTTP/3, and SSE only
  needs content-type + no-cache.
- Extract the SSE emission into internal WriteMessageStreamAsync with the token
  source and the model-serialisation gate injected, so the event sequence is
  testable without loading a model. Behaviour is unchanged: the gate still wraps
  only the generation loop, so message_start is emitted before queueing.
- docs/ANTHROPIC_API.md: drop the lora_adapter example — per-request adapter
  selection is not implemented on the server (the OpenAI surface does not honour
  it either); document the role restriction instead.

Tests: 49 (was 26) — endpoint-level SSE tests asserting event names, ordering and
payload shapes for text-only, max_tokens, stop_sequence and tool_use streams,
plus ParseInput non-object and role/content validation regressions.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@jamesburton

Copy link
Copy Markdown
Author

Thanks for the review — all six comments were assessed individually; five are addressed in 85b64de, and one is addressed with a scope caveat. Details below.

1. ParseInput can return a non-object root — valid, fixed

Correct: Anthropic requires tool_use.input to be an object, and a model that ignores the tool schema can emit "hi" / [] / 42. ParseInput now checks RootElement.ValueKind == JsonValueKind.Object and collapses anything else to {}, matching the existing invalid-JSON behaviour. Covered by a new ParseInput_NonObjectRoot_ReturnsEmptyObject theory (string, empty array, non-empty array, number, null, true) plus a pass-through case for a real object.

2. EmptyObject() reparses {} on every call — valid, fixed

A cloned JsonElement is detached and immutable, so a single instance can safely be handed out for every empty/invalid input. Now a static readonly JsonElement EmptyObject initialised once.

3. ValidateRequest doesn't check roles or content kinds — valid, fixed

AnthropicConverter.ToMessages does pass msg.Role straight through to the chat template, so an unchecked system role let a caller inject a system turn mid-conversation, and a non-string/non-array content silently flattened to an empty message rather than surfacing as a 400. ValidateRequest now rejects any messages[].role other than user/assistant, and any messages[].content whose ValueKind is not String or Array (which also catches an entirely missing content, previously Undefined → empty message).

The checks run after the max_tokens check so the more specific top-level error still wins. New tests: ValidateRequest_UnsupportedRole_Fails (system/tool/developer/empty), ValidateRequest_NonStringNonArrayContent_Fails (number/null/bool/bare object), ValidateRequest_MissingContent_Fails, and a positive ValidateRequest_BlockArrayContent_ReturnsNull.

4. Connection header on the SSE response — valid, fixed in this endpoint; two pre-existing occurrences flagged rather than changed

Agreed — Connection is a connection-specific header, disallowed over HTTP/2 and HTTP/3, and SSE only needs Content-Type + Cache-Control: no-cache. Removed from MessagesEndpoint, with a comment recording why so it doesn't get copy-pasted back in.

Scope note, stated explicitly because it leaves the codebase temporarily inconsistent: the same line exists in ChatCompletionEndpoint.cs:190 and CompletionEndpoint.cs:126. Both predate this PR, which is purely additive to the OpenAI surface, so I have not touched them here — happy to fix them in this PR or a follow-up, whichever you prefer.

5. Docs advertise lora_adapter but nothing honours it — valid, fixed (docs)

Confirmed by grep: lora_adapter appears nowhere in src/DotLLM.Server/ — not in AnthropicMessagesRequest, and not in the OpenAI request DTOs either, so the doc's "parity with the OpenAI surface" claim was doubly misleading. Rather than add unbacked plumbing, I removed it from the ANTHROPIC_API.md request example and replaced the bullet with an explicit statement that per-request adapter selection is not implemented and that unknown fields are ignored rather than rejected. I also added a bullet documenting the new role restriction.

(docs/SERVER.md:32 has the same stale lora_adapter in the OpenAI example — pre-existing, not changed here for the same scope reason as #4.)

6. No automated coverage of the streaming endpoint — valid, fixed

Fair: the SSE path was the largest untested surface. The blocker was that the emission was inlined in HandleStreamingAsync behind a TextGenerator and ServerState, so it could not run without a loaded model.

I extracted the emission into internal static MessagesEndpoint.WriteMessageStreamAsync, which takes the token source as Func<CancellationToken, IAsyncEnumerable<GenerationToken>> and the model-serialisation gate as Func<Func<Task>, CancellationToken, Task> (the endpoint passes generator.GenerateStreamingTokensAsync and state.ExecuteAsync). Behaviour is unchanged — in particular the gate still wraps only the generation loop, so message_start is still emitted before the request queues behind the model lock.

AnthropicStreamingTests then drives it against a DefaultHttpContext with a MemoryStream body, parses the raw SSE frames, and asserts:

  • text-only: exact event-name sequence message_start, content_block_start, ping, content_block_delta ×2, content_block_stop, message_delta, message_stop; and the payload shapes — message.id/type/role/model, stop_reason: null on message_start, block index 0, text_delta payloads, end_turn + stop_sequence: null and output_tokens on message_delta.
  • max_tokens: FinishReason.Lengthstop_reason: "max_tokens".
  • stop_sequences: a matching caller-supplied sequence → stop_reason: "stop_sequence" with the matched sequence echoed.
  • tool_use: full 10-event sequence, the tool block opening at index 1 after the text block, content_block.input being an empty object at content_block_start, the input_json_delta carrying the arguments, and stop_reason: "tool_use".
  • parser finds nothing: no tool_use block, stop_reason stays end_turn.
  • The absence of the Connection header (regression guard for Implement CPU tensor operations (MatMul, RMSNorm, SiLU, Softmax) #4).

Verification

dotnet build clean (0 warnings) for DotLLM.Server and DotLLM.Tests.Unit.

dotnet test --filter "FullyQualifiedName~Tests.Unit.Server"
Passed! - Failed: 0, Passed: 67, Skipped: 0

Anthropic-specific tests went from 26 to 49; README's test count updated accordingly. I did not run the full unit suite in this pass, and there is still no test that exercises the endpoint through the real ASP.NET routing/model-binding stack with a loaded model — the new tests stop at WriteMessageStreamAsync, so request deserialisation and the non-streaming JSON path remain covered only at the converter/validator level.

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.

server: Anthropic-compatible Messages API (/v1/messages) layer

2 participants