Fuenf Befunde mit einem gemeinsamen Fehlermodus: Keiner erzeugt eine Fehlermeldung. Der Lauf wird fertig, das Ergebnis sieht plausibel aus, und nur die Qualitaet ist schlechter. #547 Der Begleittext zum Werkzeugaufruf ging verloren. AssistantWithToolCalls baute eine neue Nachricht aus nur den Aufrufen; der Text, den das Modell daneben geschrieben hatte, fiel weg. Damit verlor das Modell zwischen zwei Schritten seinen eigenen Plan, und im Audit stand, dass ein Werkzeug lief, nicht warum. #548 finish_reason wurde nirgends gelesen. Eine an der Ausgabegrenze abgeschnittene Antwort galt als fertiges Ergebnis. Das trifft besonders Laeufe im Hintergrund, wo niemand zusieht. Neuer Status Truncated: Das Teilergebnis geht weiter, aber gekennzeichnet. #549 reasoning_details wurde verworfen. Bei Werkzeugnutzung muss der Denkverlauf unveraendert zurueck, sonst beginnt ein Thinking-Modell jedes Mal von vorn. Wird jetzt als rohes JSON durchgereicht - ohne Auslegung, weil Reihenfolge und Signatur daran haengen. #550 Die Anfrage verlangt jetzt einen Anbieter, der alle mitgeschickten Parameter beherrscht. Ohne das durfte ein Anbieter cache_control, reasoning oder sogar tools stillschweigend uebergehen - der Lauf lieferte dann eine gewoehnliche Textantwort, wo ein Werkzeugaufruf hingehoert haette. Dazu usage.include: der abgerechnete Betrag schlaegt die Eigenrechnung, die als Rueckfallebene bleibt. Denktiefe je Agent einstellbar, Vorgabe off. #551 Werkzeuge eines Zuges laufen gleichzeitig, sofern sie sich dafuer freigeben. Die Vorgabe von IsParallelSafe ist false, weil die falsche Antwort hier nicht auffaellt: Zwei schreibende Aufrufe auf derselben Datei scheitern nicht zuverlaessig, sondern gelegentlich. Freigegeben ist bisher nur WebFetch - es liest ausschliesslich. Die Reihenfolge der Ergebnisse folgt den Aufrufen, nicht der Fertigstellung; daran haengen die tool_call_id-Zuordnung und der Cache-Praefix. 27 neue Tests, 887 gesamt, keine Regression. Build ohne Warnungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
242 lines
8.7 KiB
C#
242 lines
8.7 KiB
C#
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<ToolCall>? ToolCalls { get; set; }
|
|
|
|
public string? ToolCallId { get; set; }
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[JsonIgnore]
|
|
public bool CacheBreakpoint { get; set; }
|
|
|
|
/// <summary>
|
|
/// Der Denkverlauf als Klartext, so wie der Anbieter ihn geliefert hat.
|
|
/// </summary>
|
|
public string? Reasoning { get; set; }
|
|
|
|
/// <summary>
|
|
/// Der strukturierte Denkverlauf, unverändert.
|
|
///
|
|
/// Wird bei Werkzeugnutzung in der folgenden Anfrage zurückgeschickt: Das Modell
|
|
/// setzt seine begonnene Antwort fort, statt eine neue zu beginnen. Die Abfolge
|
|
/// muss dabei genau der gelieferten entsprechen — umstellen oder verändern macht
|
|
/// sie unbrauchbar, und bei signierten Formaten kann der Anbieter sie dann nicht
|
|
/// mehr zuordnen. Deshalb wird hier rohes JSON gehalten und nichts ausgelegt.
|
|
/// </summary>
|
|
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 };
|
|
|
|
/// <summary>
|
|
/// Baut die assistant-Nachricht für einen Zug mit Werkzeugaufrufen.
|
|
///
|
|
/// <paramref name="content"/> ist der Text, den das Modell neben die Aufrufe
|
|
/// geschrieben hat („Ich sehe zuerst in der Konfiguration nach"). Er gehört in den
|
|
/// Verlauf: Ohne ihn verliert das Modell im nächsten Schritt seinen eigenen Plan,
|
|
/// und im Audit steht, <i>dass</i> ein Werkzeug lief, nicht <i>warum</i>.
|
|
/// </summary>
|
|
public static ChatMessage AssistantWithToolCalls(
|
|
List<ToolCall> 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
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Serialisiert <see cref="ChatMessage"/>. Der Inhalt geht normalerweise als einfacher
|
|
/// String raus; ist ein <see cref="ChatMessage.CacheBreakpoint"/> 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.
|
|
/// </summary>
|
|
public sealed class ChatMessageConverter : JsonConverter<ChatMessage>
|
|
{
|
|
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<List<ToolCall>>(ref reader, options);
|
|
break;
|
|
|
|
case "tool_call_id":
|
|
message.ToolCallId = reader.GetString();
|
|
break;
|
|
|
|
// Der Denkverlauf wird durchgereicht, nicht ausgelegt. "reasoning_content"
|
|
// ist derselbe Inhalt unter anderem Namen; wer zuerst kommt, gewinnt.
|
|
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(), weil der Reader nach dieser Schleife nicht mehr gilt.
|
|
message.ReasoningDetails = reader.TokenType == JsonTokenType.Null
|
|
? null
|
|
: JsonDocument.ParseValue(ref reader).RootElement.Clone();
|
|
break;
|
|
|
|
default:
|
|
reader.Skip();
|
|
break;
|
|
}
|
|
}
|
|
|
|
throw new JsonException("ChatMessage: unerwartetes Ende.");
|
|
}
|
|
|
|
/// <summary>Nimmt den Inhalt als String oder als Block-Array entgegen.</summary>
|
|
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<string>();
|
|
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);
|
|
|
|
// Unverändert zurück. Das strukturierte Feld hat Vorrang, weil nur es Signatur
|
|
// und Reihenfolge trägt; der Klartext geht mit, wenn es keines gibt.
|
|
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();
|
|
}
|
|
}
|