using System.Text.Json; using System.Text.Json.Serialization; namespace ClawdDotNet.Core.Api.Models; [JsonConverter(typeof(ChatMessageConverter))] public sealed class ChatMessage { public string Role { get; set; } = ""; public string? Content { get; set; } public List? ToolCalls { get; set; } public string? ToolCallId { get; set; } /// /// Sets a prompt caching breakpoint on this message. /// /// Everything in the prompt BEFORE the breakpoint (tool definitions, system prompt, /// previous messages) is read from the cache on the next call and /// costs only a fraction. The content is then serialized as a block array instead of a /// plain string. /// /// Important: the prompt section before the breakpoint must be identical to the character /// between two calls, otherwise the cache does not apply. /// [JsonIgnore] public bool CacheBreakpoint { get; set; } /// /// The reasoning as plain text, as the provider delivered it. /// public string? Reasoning { get; set; } /// /// The structured reasoning, unchanged. /// /// With tool use it is sent back in the following request: the model /// continues its started answer instead of beginning a new one. The sequence /// must correspond exactly to the delivered one — reordering or changing it makes /// it unusable, and with signed formats the provider can then no longer /// match it. That's why raw JSON is held here and nothing is interpreted. /// public JsonElement? ReasoningDetails { get; set; } public static ChatMessage System(string content) => new() { Role = "system", Content = content }; public static ChatMessage User(string content) => new() { Role = "user", Content = content }; public static ChatMessage Assistant(string content) => new() { Role = "assistant", Content = content }; /// /// Builds the assistant message for a turn with tool calls. /// /// is the text the model wrote next to the calls /// ("I'll look in the configuration first"). It belongs in the /// history: without it the model loses its own plan in the next step, /// and the audit says that a tool ran, not why. /// public static ChatMessage AssistantWithToolCalls( List toolCalls, string? content = null, string? reasoning = null, JsonElement? reasoningDetails = null) => new() { Role = "assistant", Content = string.IsNullOrEmpty(content) ? null : content, ToolCalls = toolCalls, Reasoning = reasoning, ReasoningDetails = reasoningDetails }; public static ChatMessage ToolResponse(string toolCallId, string content) => new() { Role = "tool", ToolCallId = toolCallId, Content = content }; } /// /// Serializes . The content normally goes out as a plain /// string; if a is set, instead /// as a block array with cache_control — the format providers expect for prompt caching. /// /// When reading, both formats are accepted: responses deliver the content as a string, /// stored contexts may contain it as an array. /// public sealed class ChatMessageConverter : JsonConverter { public override ChatMessage Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType != JsonTokenType.StartObject) throw new JsonException("ChatMessage: Objekt erwartet."); var message = new ChatMessage(); while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) return message; if (reader.TokenType != JsonTokenType.PropertyName) continue; var propertyName = reader.GetString(); reader.Read(); switch (propertyName) { case "role": message.Role = reader.GetString() ?? ""; break; case "content": message.Content = ReadContent(ref reader); break; case "tool_calls": message.ToolCalls = reader.TokenType == JsonTokenType.Null ? null : JsonSerializer.Deserialize>(ref reader, options); break; case "tool_call_id": message.ToolCallId = reader.GetString(); break; // The reasoning is passed through, not interpreted. "reasoning_content" // is the same content under a different name; whichever comes first wins. case "reasoning": case "reasoning_content": message.Reasoning ??= reader.TokenType == JsonTokenType.String ? reader.GetString() : null; if (reader.TokenType is not JsonTokenType.String and not JsonTokenType.Null) reader.Skip(); break; case "reasoning_details": // Clone(), because the reader is no longer valid after this loop. message.ReasoningDetails = reader.TokenType == JsonTokenType.Null ? null : JsonDocument.ParseValue(ref reader).RootElement.Clone(); break; default: reader.Skip(); break; } } throw new JsonException("ChatMessage: unerwartetes Ende."); } /// Accepts the content as a string or as a block array. private static string? ReadContent(ref Utf8JsonReader reader) { if (reader.TokenType == JsonTokenType.Null) return null; if (reader.TokenType == JsonTokenType.String) return reader.GetString(); if (reader.TokenType != JsonTokenType.StartArray) { reader.Skip(); return null; } // Block array: join the text parts. var parts = new List(); while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) { if (reader.TokenType != JsonTokenType.StartObject) { reader.Skip(); continue; } using var block = JsonDocument.ParseValue(ref reader); if (block.RootElement.TryGetProperty("text", out var text) && text.GetString() is { } value) { parts.Add(value); } } return parts.Count > 0 ? string.Join("", parts) : null; } public override void Write(Utf8JsonWriter writer, ChatMessage value, JsonSerializerOptions options) { writer.WriteStartObject(); writer.WriteString("role", value.Role); if (value.Content is not null) { if (value.CacheBreakpoint) { // [{ "type": "text", "text": "…", "cache_control": { "type": "ephemeral" } }] writer.WriteStartArray("content"); writer.WriteStartObject(); writer.WriteString("type", "text"); writer.WriteString("text", value.Content); writer.WriteStartObject("cache_control"); writer.WriteString("type", "ephemeral"); writer.WriteEndObject(); writer.WriteEndObject(); writer.WriteEndArray(); } else { writer.WriteString("content", value.Content); } } if (value.ToolCalls is { Count: > 0 }) { writer.WritePropertyName("tool_calls"); JsonSerializer.Serialize(writer, value.ToolCalls, options); } if (value.ToolCallId is not null) writer.WriteString("tool_call_id", value.ToolCallId); // Back unchanged. The structured field takes precedence because only it carries signature // and order; the plain text goes along if there is none. if (value.ReasoningDetails is { } details) { writer.WritePropertyName("reasoning_details"); details.WriteTo(writer); } else if (value.Reasoning is not null) { writer.WriteString("reasoning", value.Reasoning); } writer.WriteEndObject(); } }