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; } /// /// Setzt einen Prompt-Caching-Breakpoint auf diese Nachricht. /// /// Alles, was im Prompt VOR dem Breakpoint steht (Tool-Definitionen, System-Prompt, /// vorherige Nachrichten), wird beim nächsten Aufruf aus dem Cache gelesen und /// kostet nur einen Bruchteil. Der Inhalt wird dann als Block-Array statt als /// einfacher String serialisiert. /// /// Wichtig: Der Prompt-Abschnitt vor dem Breakpoint muss zwischen zwei Aufrufen /// zeichengenau identisch sein, sonst greift der Cache nicht. /// [JsonIgnore] public bool CacheBreakpoint { 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 }; public static ChatMessage AssistantWithToolCalls(List toolCalls) => new() { Role = "assistant", ToolCalls = toolCalls }; public static ChatMessage ToolResponse(string toolCallId, string content) => new() { Role = "tool", ToolCallId = toolCallId, Content = content }; } /// /// Serialisiert . Der Inhalt geht normalerweise als einfacher /// String raus; ist ein gesetzt, stattdessen /// als Block-Array mit cache_control — das Format, das Anbieter für Prompt-Caching /// erwarten. /// /// Beim Lesen werden beide Formate akzeptiert: Antworten liefern den Inhalt als String, /// gespeicherte Kontexte können ihn als Array enthalten. /// 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; default: reader.Skip(); break; } } throw new JsonException("ChatMessage: unerwartetes Ende."); } /// Nimmt den Inhalt als String oder als Block-Array entgegen. 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: die text-Anteile zusammenführen. 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); writer.WriteEndObject(); } }