Translate.OpenAIMessagesToAnthropic
C# · package Toolnexus · SPEC §11 · Translate.cs
public static class Translate{ public sealed record Converted(List<object?> Messages, string System);
public static Converted OpenAIMessagesToAnthropic(IEnumerable<object?>? messages);}Converts an OpenAI messages array into Anthropic-native messages plus the extracted system
prompt — the exact conversion LlmClient.TranslateAsync uses
internally when Style = "anthropic". Three rules a naive text-flattening translator gets wrong:
an assistant turn’s tool_calls become tool_use blocks (arguments parsed back from their JSON
string into an object); a tool-role result becomes a tool_result block keyed by
tool_call_id; and consecutive tool results merge into one user turn, because Anthropic
expects a single result-bearing turn answering the preceding assistant turn, not one turn per
result. system/developer messages are hoisted out into the returned System string.
When to use it
Section titled “When to use it”You’re building your own translation path — a custom proxy, a test harness, tooling that inspects what an OpenAI-shaped transcript would look like on Anthropic’s wire — and want exactly this conversion without going through a live provider call. It’s also the reference to check your own work against if you ever need to hand-roll a similar conversion for a different provider shape.
Why this and not the alternative
Section titled “Why this and not the alternative”Examples
Section titled “Examples”1. The smallest useful call — a plain user turn plus a hoisted system message
Section titled “1. The smallest useful call — a plain user turn plus a hoisted system message”using Toolnexus;
object? Msg(string role, string content) => new Dictionary<string, object?> { ["role"] = role, ["content"] = content };
var messages = new List<object?>{ Msg("system", "You are terse."), Msg("user", "hello"),};
var converted = Translate.OpenAIMessagesToAnthropic(messages);
if (converted.System != "You are terse.") throw new Exception(converted.System);if (converted.Messages.Count != 1) throw new Exception($"expected 1 message, got {converted.Messages.Count}");var only = (IDictionary<string, object?>)converted.Messages[0]!;if (only["role"] as string != "user" || only["content"] as string != "hello") throw new Exception("user turn mismatch");
Console.WriteLine($"ok: system='{converted.System}', {converted.Messages.Count} message(s)");2. The realistic case — tool calls become tool_use, results MERGE into one user turn
Section titled “2. The realistic case — tool calls become tool_use, results MERGE into one user turn”using System.Linq;using Toolnexus;
object? Msg(string role, string content) => new Dictionary<string, object?> { ["role"] = role, ["content"] = content };object? AssistantToolCalls(params (string id, string name, string args)[] calls) => new Dictionary<string, object?>{ ["role"] = "assistant", ["content"] = (string?)null, ["tool_calls"] = calls.Select(c => (object?)new Dictionary<string, object?> { ["id"] = c.id, ["type"] = "function", ["function"] = new Dictionary<string, object?> { ["name"] = c.name, ["arguments"] = c.args }, }).ToList(),};object? ToolResult(string id, string output) => new Dictionary<string, object?> { ["role"] = "tool", ["tool_call_id"] = id, ["content"] = output };
var messages = new List<object?>{ Msg("user", "check the weather and the time in Chennai"), // The model called TWO tools in one turn — two OpenAI tool_calls, two tool-role results. AssistantToolCalls(("c1", "get_weather", "{\"city\":\"Chennai\"}"), ("c2", "get_time", "{\"city\":\"Chennai\"}")), ToolResult("c1", "31C, humid"), ToolResult("c2", "14:20 IST"),};
var converted = Translate.OpenAIMessagesToAnthropic(messages);
// [user, assistant(tool_use x2), user(tool_result x2)] — the two consecutive tool results MERGE// into ONE user turn, exactly as Anthropic expects.if (converted.Messages.Count != 3) throw new Exception($"expected 3 messages, got {converted.Messages.Count}");
var assistantMsg = (IDictionary<string, object?>)converted.Messages[1]!;var blocks = (List<object?>)assistantMsg["content"]!;if (blocks.Count != 2) throw new Exception($"expected 2 tool_use blocks, got {blocks.Count}");var firstUse = (IDictionary<string, object?>)blocks[0]!;if (firstUse["type"] as string != "tool_use" || firstUse["name"] as string != "get_weather") throw new Exception("tool_use shape");// arguments parsed BACK from JSON string into an object.var input = (IDictionary<string, object?>)firstUse["input"]!;if (input["city"] as string != "Chennai") throw new Exception("arguments must be parsed, not left as a string");
var mergedResults = (IDictionary<string, object?>)converted.Messages[2]!;var resultBlocks = (List<object?>)mergedResults["content"]!;if (mergedResults["role"] as string != "user" || resultBlocks.Count != 2) throw new Exception("both tool results must merge into ONE user turn");
Console.WriteLine($"ok: {converted.Messages.Count} messages, {resultBlocks.Count} tool_result blocks merged into 1 turn");3. Full surface — feeding the conversion into a real Anthropic-shaped call
Section titled “3. Full surface — feeding the conversion into a real Anthropic-shaped call”using System.Net;using System.Text;using Toolnexus;
var receivedBody = "";using var stub = new Stub(ctx =>{ using var reader = new StreamReader(ctx.Request.InputStream); receivedBody = reader.ReadToEnd(); Stub.Json(ctx, 200, """ {"id":"m1","model":"claude-3-5-sonnet","stop_reason":"end_turn","content":[{"type":"text","text":"It's 31C and 14:20 IST in Chennai."}],"usage":{"input_tokens":30,"output_tokens":10}} """);});
object? Msg(string role, string content) => new Dictionary<string, object?> { ["role"] = role, ["content"] = content };object? ToolResult(string id, string output) => new Dictionary<string, object?> { ["role"] = "tool", ["tool_call_id"] = id, ["content"] = output };object? AssistantToolCalls((string id, string name, string args) c) => new Dictionary<string, object?>{ ["role"] = "assistant", ["content"] = (string?)null, ["tool_calls"] = new List<object?> { new Dictionary<string, object?> { ["id"] = c.id, ["type"] = "function", ["function"] = new Dictionary<string, object?> { ["name"] = c.name, ["arguments"] = c.args } } },};
// Confirm the pure conversion first, standalone.var converted = Translate.OpenAIMessagesToAnthropic(new List<object?>{ Msg("system", "Answer in one sentence."), Msg("user", "weather and time in Chennai?"), AssistantToolCalls(("c1", "get_weather_and_time", "{\"city\":\"Chennai\"}")), ToolResult("c1", "31C, humid; 14:20 IST"),});if (converted.System != "Answer in one sentence.") throw new Exception(converted.System);
// Then confirm TranslateAsync uses that SAME conversion end-to-end (Style = "anthropic").var client = LlmClient.Create(new LlmClient.Options { BaseUrl = stub.BaseUrl, Style = "anthropic", Model = "claude-3-5-sonnet", ApiKey = "test-key" });var result = await client.TranslateAsync(new Translate.Request{ Messages = new List<object?> { Msg("system", "Answer in one sentence."), Msg("user", "weather and time in Chennai?"), AssistantToolCalls(("c1", "get_weather_and_time", "{\"city\":\"Chennai\"}")), ToolResult("c1", "31C, humid; 14:20 IST"), },});
if (result.Text != "It's 31C and 14:20 IST in Chennai.") throw new Exception(result.Text);if (!receivedBody.Contains("\"system\":\"Answer in one sentence.\"")) throw new Exception("hoisted system must reach the wire");if (!receivedBody.Contains("tool_result")) throw new Exception("the merged tool_result block must reach the wire");
Console.WriteLine($"ok: {result.Text}");
sealed class Stub : IDisposable{ readonly HttpListener _listener = new(); readonly CancellationTokenSource _cts = new(); public int Port { get; } public string BaseUrl => $"http://127.0.0.1:{Port}";
public Stub(Action<HttpListenerContext> handler) { var probe = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0); probe.Start(); Port = ((IPEndPoint)probe.LocalEndpoint).Port; probe.Stop(); _listener.Prefixes.Add($"http://127.0.0.1:{Port}/"); _listener.Start(); _ = Task.Run(async () => { while (!_cts.IsCancellationRequested) { HttpListenerContext ctx; try { ctx = await _listener.GetContextAsync(); } catch { break; } try { handler(ctx); } catch { } } }); }
public static void Json(HttpListenerContext ctx, int status, string body) { var bytes = Encoding.UTF8.GetBytes(body); ctx.Response.StatusCode = status; ctx.Response.ContentType = "application/json"; ctx.Response.ContentLength64 = bytes.Length; ctx.Response.OutputStream.Write(bytes, 0, bytes.Length); ctx.Response.OutputStream.Close(); }
public void Dispose() { _cts.Cancel(); try { _listener.Stop(); } catch { } try { _listener.Close(); } catch { } }}Parameters
Section titled “Parameters”| Parameter | Type | What it is |
|---|---|---|
messages |
IEnumerable<object?>? |
An OpenAI messages array. null is treated as empty. |
Returns (Converted)
Section titled “Returns (Converted)”| Field | Type | What it is |
|---|---|---|
Messages |
List<object?> |
Anthropic-native messages: tool_use/tool_result blocks, consecutive tool results merged. |
System |
string |
The hoisted system/developer content, joined with blank lines. "" if none was present. |
See also
Section titled “See also”LlmClient.TranslateAsync— Uses this conversion internally for an Anthropic-style upstream, plus the live provider call.