Skip to content

Translate.openAIMessagesToAnthropic

Java · package io.github.muthuishere:toolnexus · SPEC §11 · Translate.java

public record Converted(List<Object> messages, String system)
public static Converted openAIMessagesToAnthropic(List<Object> messages)

The pure conversion function LlmClient.translate uses internally on the Anthropic path — exposed directly for callers who want the shape, not a provider call. Converts an OpenAI messages array into Anthropic-native messages plus the extracted system prompt: an assistant turn’s tool_calls become tool_use blocks (with arguments parsed back from its JSON string into an object), a tool-role result becomes a tool_result block keyed by tool_call_id, consecutive tool results merge into one user turn (providers expect a single result-bearing turn answering the preceding assistant turn, not one turn per result), and system/developer messages are hoisted out since Anthropic takes system separately. This is exactly “the part a text-flattening translator gets wrong” — collapsing tool structure to prose loses the tool_call_id correlation a provider needs to replay the transcript.

You’re building something that needs Anthropic-shaped messages from an OpenAI-shaped transcript but isn’t making a provider call through translate — inspecting what the wire payload would look like, feeding a different Anthropic-speaking client, or testing your own OpenAI-side message construction against what a conforming translator should produce.

1. The smallest useful call — tool_calls become tool_use, arguments re-parsed to an object

Section titled “1. The smallest useful call — tool_calls become tool_use, arguments re-parsed to an object”
import io.github.muthuishere.toolnexus.Translate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Example {
public static void main(String[] args) {
Map<String, Object> assistant = new LinkedHashMap<>();
assistant.put("role", "assistant");
assistant.put("tool_calls", List.of(Map.of(
"id", "call_abc", "type", "function",
"function", Map.of("name", "get_weather", "arguments", "{\"city\":\"Chennai\"}"))));
Translate.Converted converted = Translate.openAIMessagesToAnthropic(List.of(
Map.of("role", "user", "content", "weather in Chennai?"),
assistant));
List<Object> out = converted.messages();
if (out.size() != 2) throw new AssertionError(out);
Map<String, Object> assistantOut = (Map<String, Object>) out.get(1);
List<Object> blocks = (List<Object>) assistantOut.get("content");
Map<String, Object> toolUse = (Map<String, Object>) blocks.get(0);
if (!"tool_use".equals(toolUse.get("type"))) throw new AssertionError(toolUse);
// arguments is re-parsed from its JSON STRING into an OBJECT for the tool_use block.
Map<String, Object> input = (Map<String, Object>) toolUse.get("input");
if (!"Chennai".equals(input.get("city"))) throw new AssertionError(input);
System.out.println("ok: " + toolUse.get("name") + " input=" + input);
}
}

2. The realistic case — three consecutive tool results merge into ONE user turn

Section titled “2. The realistic case — three consecutive tool results merge into ONE user turn”
import io.github.muthuishere.toolnexus.Translate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Example {
public static void main(String[] args) {
Map<String, Object> assistant = new LinkedHashMap<>();
assistant.put("role", "assistant");
assistant.put("tool_calls", List.of(
Map.of("id", "a", "function", Map.of("name", "f", "arguments", "{}")),
Map.of("id", "b", "function", Map.of("name", "f", "arguments", "{}")),
Map.of("id", "c", "function", Map.of("name", "f", "arguments", "{}"))));
Translate.Converted converted = Translate.openAIMessagesToAnthropic(List.of(
Map.of("role", "user", "content", "do three things"),
assistant,
Map.of("role", "tool", "tool_call_id", "a", "content", "ra"),
Map.of("role", "tool", "tool_call_id", "b", "content", "rb"),
Map.of("role", "tool", "tool_call_id", "c", "content", "rc")));
int resultTurns = 0;
int resultsInTurn = 0;
for (Object m : converted.messages()) {
Map<String, Object> mm = (Map<String, Object>) m;
if (!(mm.get("content") instanceof List<?> blocks)) continue;
int n = 0;
for (Object b : blocks) {
if (b instanceof Map<?, ?> bm && "tool_result".equals(bm.get("type"))) n++;
}
if (n > 0) { resultTurns++; resultsInTurn = n; }
}
if (resultTurns != 1) throw new AssertionError("results spread over " + resultTurns + " turns");
if (resultsInTurn != 3) throw new AssertionError("merged turn carries " + resultsInTurn + " results");
System.out.println("ok: " + resultTurns + " turn carrying " + resultsInTurn + " tool_result blocks");
}
}

3. The full surface — system/developer hoisted out, content parts flattened, object-form arguments accepted

Section titled “3. The full surface — system/developer hoisted out, content parts flattened, object-form arguments accepted”
import io.github.muthuishere.toolnexus.Translate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class Example {
public static void main(String[] args) {
// (a) system + developer messages are hoisted out of the message list.
Translate.Converted withSystem = Translate.openAIMessagesToAnthropic(List.of(
Map.of("role", "system", "content", "Be terse."),
Map.of("role", "developer", "content", "Prefer metric units."),
Map.of("role", "user", "content", "hi")));
if (!withSystem.system().equals("Be terse.\n\nPrefer metric units.")) {
throw new AssertionError(withSystem.system());
}
boolean anySystemLeftInMessages = withSystem.messages().stream()
.anyMatch(m -> "system".equals(((Map<?, ?>) m).get("role"))
|| "developer".equals(((Map<?, ?>) m).get("role")));
if (anySystemLeftInMessages) throw new AssertionError("system/developer must be hoisted out");
// (b) a content-parts array is flattened to plain text.
Translate.Converted parts = Translate.openAIMessagesToAnthropic(List.of(
Map.of("role", "user", "content", List.of(
Map.of("type", "text", "text", "part one "),
Map.of("type", "text", "text", "part two")))));
Map<String, Object> flattenedUserTurn = (Map<String, Object>) parts.messages().get(0);
if (!"part one part two".equals(flattenedUserTurn.get("content"))) {
throw new AssertionError("content parts were not flattened: " + flattenedUserTurn.get("content"));
}
// (c) some callers send `arguments` as an OBJECT rather than a JSON string — accepted too.
Map<String, Object> assistant = new LinkedHashMap<>();
assistant.put("role", "assistant");
assistant.put("tool_calls", List.of(Map.of("id", "z",
"function", Map.of("name", "f", "arguments", Map.of("city", "Madurai")))));
Translate.Converted objectArgs = Translate.openAIMessagesToAnthropic(List.of(
Map.of("role", "user", "content", "go"), assistant,
Map.of("role", "tool", "tool_call_id", "z", "content", "done")));
Map<String, Object> assistantOut = (Map<String, Object>) objectArgs.messages().get(1);
List<Object> blocks = (List<Object>) assistantOut.get("content");
Map<String, Object> toolUse = (Map<String, Object>) blocks.get(0);
Map<String, Object> input = (Map<String, Object>) toolUse.get("input");
if (!"Madurai".equals(input.get("city"))) throw new AssertionError("object-form arguments lost: " + input);
System.out.println("ok: system=[" + withSystem.system() + "] object-args city=" + input.get("city"));
}
}
Member Type What it is
openAIMessagesToAnthropic(messages) Converted Converts one OpenAI messages array.
Converted.messages List<Object> Anthropic-native messages: tool_use/tool_result blocks, merged consecutive results.
Converted.system String The hoisted system prompt, \n\n-joined from any system/developer messages found.
  • LlmClient.translate — Declare a toolkit to a provider and translate one request/response without executing anything or keeping state; uses this conversion internally on the Anthropic path.