feat: OpenAI-kompatibler Provider (Nvidia/g4f.space) mit Tool-Calling und SSE-Streaming
- OpenAiService.cs: Neuer Service für OpenAI-kompatible APIs - SSE-Streaming mit text_start/text_delta/text_end Events - Tool-Calling: list_files, read_file, write_file, edit_file, delete_file, execute - Agentic Loop (max 10 Iterationen) - Tool-Use + Tool-Result in DB gespeichert - History-Rebuild: Anthropic-Format → OpenAI-Format (tool_use/tool_result korrekt konvertiert) - ChatContext.cs: Provider-Feld hinzugefügt - ChatService.cs: OpenAI-Provider-Erkennung in ResolveModelId + PrepareContext - ChatEndpoint.cs: Provider-Dispatch (nvidia → OpenAiService, sonst → AnthropicService) - Program.cs: OpenAiService als Singleton registriert - wwwroot/index.html: nvidia:moonshotai/kimi-k2.5 in Modell-Auswahl
This commit is contained in:
1 parent
a646e695cd
commit
7972ca6a3a
6 files changed
+665
-2
No files matched your search
@@ -30,6 +30,9 @@ public class ChatService
|
||||
|
||||
public static string ResolveModelId(string model)
|
||||
{
|
||||
// OpenAI-kompatible Provider werden unverändert durchgereicht (Format: "provider:model")
|
||||
if (OpenAiService.IsOpenAiProvider(model)) return model;
|
||||
|
||||
return model switch
|
||||
{
|
||||
"sonnet" => "claude-sonnet-4-6",
|
||||
@@ -160,11 +163,14 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
|
||||
reviewContext.MaxReviewIterations = session.MaxReviewIterations > 0 ? session.MaxReviewIterations : 3;
|
||||
|
||||
|
||||
var (provider, _) = OpenAiService.ParseModelString(modelId);
|
||||
|
||||
return new ChatContext
|
||||
{
|
||||
ChainId = chainId,
|
||||
SessionId = sessionId,
|
||||
ModelId = modelId,
|
||||
Provider = provider,
|
||||
Workspace = workspace,
|
||||
AgentId = agent.AgentId,
|
||||
SystemPromptText = systemPromptText,
|
||||
|
||||
@@ -0,0 +1,640 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Gott.Models;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace Gott.Services;
|
||||
|
||||
/// <summary>
|
||||
/// OpenAI-kompatibler Service – unterstützt Nvidia (g4f.space Proxy) und andere
|
||||
/// OpenAI-API-kompatible Endpunkte. Unterstützt Tool-Calling (list_files, read_file,
|
||||
/// write_file, edit_file, delete_file, execute) und Streaming via SSE.
|
||||
/// </summary>
|
||||
public class OpenAiService
|
||||
{
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly DatabaseService _db;
|
||||
private readonly ToolService _toolService;
|
||||
|
||||
// Provider-Definitionen
|
||||
private static readonly Dictionary<string, (string BaseUrl, string ApiKey)> Providers = new()
|
||||
{
|
||||
["nvidia"] = ("https://g4f.space/api/nvidia/v1", "")
|
||||
};
|
||||
|
||||
// Tools die für OpenAI-kompatible Provider verfügbar sind
|
||||
private static readonly List<object> OpenAiTools = new()
|
||||
{
|
||||
new
|
||||
{
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = "list_files",
|
||||
description = "Listet Dateien und Ordner in einem Verzeichnis auf.",
|
||||
parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["path"] = new { type = "string", description = "Relativer Verzeichnispfad (leer = Root)" }
|
||||
},
|
||||
required = Array.Empty<string>()
|
||||
}
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = "read_file",
|
||||
description = "Liest den Inhalt einer Datei. Standardmäßig werden maximal 2000 Zeilen gelesen. Bei großen Dateien offset und limit verwenden.",
|
||||
parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["path"] = new { type = "string", description = "Relativer Dateipfad" },
|
||||
["offset"] = new { type = "integer", description = "Erste Zeile die gelesen werden soll (1-basiert, Default: 1)" },
|
||||
["limit"] = new { type = "integer", description = "Maximale Anzahl Zeilen die gelesen werden sollen (Default: 2000)" }
|
||||
},
|
||||
required = new[] { "path" }
|
||||
}
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = "write_file",
|
||||
description = "Erstellt oder überschreibt eine Datei mit dem angegebenen Inhalt.",
|
||||
parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["path"] = new { type = "string", description = "Relativer Dateipfad" },
|
||||
["content"] = new { type = "string", description = "Dateiinhalt" }
|
||||
},
|
||||
required = new[] { "path", "content" }
|
||||
}
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = "edit_file",
|
||||
description = "Ersetzt einen exakten Textabschnitt in einer Datei. oldText muss exakt matchen.",
|
||||
parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["path"] = new { type = "string", description = "Relativer Dateipfad" },
|
||||
["old_text"] = new { type = "string", description = "Exakter Text der ersetzt werden soll" },
|
||||
["new_text"] = new { type = "string", description = "Neuer Text" }
|
||||
},
|
||||
required = new[] { "path", "old_text", "new_text" }
|
||||
}
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = "delete_file",
|
||||
description = "Löscht eine Datei oder ein leeres Verzeichnis.",
|
||||
parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["path"] = new { type = "string", description = "Relativer Pfad zur Datei oder zum Verzeichnis" }
|
||||
},
|
||||
required = new[] { "path" }
|
||||
}
|
||||
}
|
||||
},
|
||||
new
|
||||
{
|
||||
type = "function",
|
||||
function = new
|
||||
{
|
||||
name = "execute",
|
||||
description = "Führt einen Shell-Befehl im Workspace-Verzeichnis aus. Gibt stdout und stderr zurück.",
|
||||
parameters = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new Dictionary<string, object>
|
||||
{
|
||||
["command"] = new { type = "string", description = "Shell-Befehl zum Ausführen" }
|
||||
},
|
||||
required = new[] { "command" }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
public OpenAiService(IHttpClientFactory httpFactory, DatabaseService db, ToolService toolService)
|
||||
{
|
||||
_httpFactory = httpFactory;
|
||||
_db = db;
|
||||
_toolService = toolService;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gibt den Provider und das eigentliche Modell-ID aus einem kombinierten Model-String zurück.
|
||||
/// Format: "nvidia:moonshotai/kimi-k2.5" → ("nvidia", "moonshotai/kimi-k2.5")
|
||||
/// </summary>
|
||||
public static (string provider, string modelId) ParseModelString(string model)
|
||||
{
|
||||
if (string.IsNullOrEmpty(model)) return ("", model ?? "");
|
||||
var idx = model.IndexOf(':');
|
||||
if (idx < 0) return ("", model);
|
||||
return (model[..idx], model[(idx + 1)..]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Prüft ob ein Model-String einen externen OpenAI-kompatiblen Provider referenziert.
|
||||
/// </summary>
|
||||
public static bool IsOpenAiProvider(string model)
|
||||
{
|
||||
var (provider, _) = ParseModelString(model);
|
||||
return Providers.ContainsKey(provider);
|
||||
}
|
||||
|
||||
public async Task StreamChat(
|
||||
HttpResponse response,
|
||||
string modelString,
|
||||
string systemPromptText,
|
||||
List<object> messages,
|
||||
string chainId,
|
||||
string workspace = null,
|
||||
string sessionId = null,
|
||||
string agentId = null,
|
||||
int maxIterations = 10,
|
||||
CancellationToken workCt = default,
|
||||
CancellationToken sseCt = default)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var usage = new TokenUsage();
|
||||
var iteration = 0;
|
||||
|
||||
var chain = new ChainRecord
|
||||
{
|
||||
ChainId = chainId,
|
||||
SessionId = sessionId,
|
||||
Model = modelString
|
||||
};
|
||||
|
||||
var (provider, modelId) = ParseModelString(modelString);
|
||||
if (!Providers.TryGetValue(provider, out var providerConfig))
|
||||
{
|
||||
var errMsg = $"Unbekannter Provider: '{provider}'";
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId, Iteration = 1,
|
||||
Role = "system", Type = "error",
|
||||
Content = errMsg, IsError = true
|
||||
});
|
||||
await TryWriteSse(response, "error", new { error = errMsg }, sseCt);
|
||||
await TryWriteSse(response, "done", new { chainId }, sseCt);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// OpenAI-kompatibles Messages-Format aufbauen
|
||||
var apiMessages = new List<object>();
|
||||
|
||||
// System-Prompt als erste System-Message
|
||||
if (!string.IsNullOrEmpty(systemPromptText))
|
||||
apiMessages.Add(new { role = "system", content = systemPromptText });
|
||||
|
||||
// History-Messages konvertieren (Anthropic-Format → OpenAI-Format)
|
||||
foreach (var msg in messages)
|
||||
{
|
||||
var converted = ConvertMessage(msg);
|
||||
if (converted == null) continue;
|
||||
|
||||
// Tool-Results kommen als List<object> zurück → einzeln expandieren
|
||||
if (converted is List<object> toolResultList)
|
||||
{
|
||||
foreach (var tr in toolResultList)
|
||||
apiMessages.Add(tr);
|
||||
continue;
|
||||
}
|
||||
|
||||
apiMessages.Add(converted);
|
||||
}
|
||||
|
||||
// Agentic Loop
|
||||
while (iteration < maxIterations)
|
||||
{
|
||||
workCt.ThrowIfCancellationRequested();
|
||||
iteration++;
|
||||
|
||||
var client = CreateClient(providerConfig.ApiKey);
|
||||
var payload = new Dictionary<string, object>
|
||||
{
|
||||
["model"] = modelId,
|
||||
["messages"] = apiMessages,
|
||||
["stream"] = true,
|
||||
["max_tokens"] = 16384,
|
||||
["tools"] = OpenAiTools,
|
||||
["tool_choice"] = "auto"
|
||||
};
|
||||
|
||||
var jsonPayload = JsonSerializer.Serialize(payload);
|
||||
var httpContent = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
|
||||
|
||||
using var httpResp = await client.SendAsync(
|
||||
new HttpRequestMessage(HttpMethod.Post, $"{providerConfig.BaseUrl}/chat/completions") { Content = httpContent },
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
workCt
|
||||
);
|
||||
|
||||
if (!httpResp.IsSuccessStatusCode)
|
||||
{
|
||||
var errBody = await httpResp.Content.ReadAsStringAsync(workCt);
|
||||
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId, Iteration = iteration,
|
||||
Role = "system", Type = "error",
|
||||
Content = errMsg, IsError = true
|
||||
});
|
||||
await TryWriteSse(response, "error", new { error = errMsg }, sseCt);
|
||||
await FinalizeChain(chain, usage, iteration, "error", sw);
|
||||
await TryWriteSse(response, "done", new { chainId }, sseCt);
|
||||
return;
|
||||
}
|
||||
|
||||
// SSE-Stream lesen
|
||||
using var stream = await httpResp.Content.ReadAsStreamAsync(workCt);
|
||||
using var reader = new System.IO.StreamReader(stream);
|
||||
|
||||
var fullText = new StringBuilder();
|
||||
// Tool-Call Akkumulatoren: id → (name, arguments)
|
||||
var toolCallAccumulators = new Dictionary<int, (string Id, string Name, StringBuilder Args)>();
|
||||
var textStarted = false;
|
||||
var stopReason = "end_turn";
|
||||
|
||||
string line;
|
||||
while ((line = await reader.ReadLineAsync(workCt)) != null)
|
||||
{
|
||||
workCt.ThrowIfCancellationRequested();
|
||||
if (!line.StartsWith("data:")) continue;
|
||||
|
||||
var data = line[5..].Trim();
|
||||
if (data == "[DONE]") break;
|
||||
|
||||
JsonElement parsed;
|
||||
try { parsed = JsonSerializer.Deserialize<JsonElement>(data); }
|
||||
catch { continue; }
|
||||
|
||||
if (!parsed.TryGetProperty("choices", out var choices)) continue;
|
||||
foreach (var choice in choices.EnumerateArray())
|
||||
{
|
||||
// Finish reason
|
||||
if (choice.TryGetProperty("finish_reason", out var finishProp) && finishProp.ValueKind != JsonValueKind.Null)
|
||||
{
|
||||
var fr = finishProp.GetString() ?? "";
|
||||
if (fr == "tool_calls") stopReason = "tool_use";
|
||||
else if (fr == "stop") stopReason = "end_turn";
|
||||
}
|
||||
|
||||
if (!choice.TryGetProperty("delta", out var delta)) continue;
|
||||
|
||||
// Text-Delta
|
||||
if (delta.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var chunk = contentProp.GetString();
|
||||
if (!string.IsNullOrEmpty(chunk))
|
||||
{
|
||||
if (!textStarted)
|
||||
{
|
||||
await TryWriteSse(response, "text_start", new { index = 0 }, sseCt);
|
||||
textStarted = true;
|
||||
}
|
||||
fullText.Append(chunk);
|
||||
await TryWriteSse(response, "text_delta", new { text = chunk }, sseCt);
|
||||
}
|
||||
}
|
||||
|
||||
// Tool-Call Deltas akkumulieren
|
||||
if (delta.TryGetProperty("tool_calls", out var toolCallsProp))
|
||||
{
|
||||
foreach (var tc in toolCallsProp.EnumerateArray())
|
||||
{
|
||||
var idx = tc.TryGetProperty("index", out var idxProp) ? idxProp.GetInt32() : 0;
|
||||
if (!toolCallAccumulators.ContainsKey(idx))
|
||||
toolCallAccumulators[idx] = ("", "", new StringBuilder());
|
||||
|
||||
var (accId, accName, accArgs) = toolCallAccumulators[idx];
|
||||
|
||||
if (tc.TryGetProperty("id", out var idProp))
|
||||
accId = idProp.GetString() ?? accId;
|
||||
|
||||
if (tc.TryGetProperty("function", out var funcProp))
|
||||
{
|
||||
if (funcProp.TryGetProperty("name", out var nameProp2))
|
||||
accName = nameProp2.GetString() ?? accName;
|
||||
if (funcProp.TryGetProperty("arguments", out var argsProp))
|
||||
accArgs.Append(argsProp.GetString() ?? "");
|
||||
}
|
||||
|
||||
toolCallAccumulators[idx] = (accId, accName, accArgs);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage aus letztem Chunk
|
||||
if (parsed.TryGetProperty("usage", out var usageProp))
|
||||
{
|
||||
if (usageProp.TryGetProperty("prompt_tokens", out var pt))
|
||||
usage.InputTokens = pt.GetInt64();
|
||||
if (usageProp.TryGetProperty("completion_tokens", out var ct2))
|
||||
usage.OutputTokens = ct2.GetInt64();
|
||||
}
|
||||
}
|
||||
|
||||
// Text-End senden falls Text gestreamt wurde
|
||||
if (textStarted)
|
||||
await TryWriteSse(response, "text_end", new { }, sseCt);
|
||||
|
||||
// Text in DB speichern
|
||||
var finalText = fullText.ToString();
|
||||
if (!string.IsNullOrEmpty(finalText))
|
||||
{
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId, Iteration = iteration,
|
||||
Role = "assistant", Type = "text",
|
||||
Content = finalText
|
||||
});
|
||||
}
|
||||
|
||||
// Keine Tool-Calls → fertig
|
||||
if (toolCallAccumulators.Count == 0 || stopReason != "tool_use")
|
||||
{
|
||||
await FinalizeChain(chain, usage, iteration, stopReason, sw);
|
||||
await TryWriteSse(response, "done", new
|
||||
{
|
||||
model = modelString,
|
||||
inputTokens = usage.InputTokens,
|
||||
outputTokens = usage.OutputTokens,
|
||||
iterations = iteration,
|
||||
chainId,
|
||||
durationMs = sw.ElapsedMilliseconds,
|
||||
stopReason
|
||||
}, sseCt);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tool-Calls ausführen
|
||||
// Assistant-Message mit tool_calls in die History einfügen
|
||||
var toolCallsForHistory = new List<object>();
|
||||
foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators)
|
||||
{
|
||||
toolCallsForHistory.Add(new
|
||||
{
|
||||
id = tcId,
|
||||
type = "function",
|
||||
function = new { name = tcName, arguments = tcArgs.ToString() }
|
||||
});
|
||||
|
||||
// Tool-Use in DB speichern
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId, Iteration = iteration,
|
||||
Role = "assistant", Type = "tool_use",
|
||||
Content = tcArgs.ToString(),
|
||||
ToolCallId = tcId, ToolName = tcName
|
||||
});
|
||||
}
|
||||
|
||||
apiMessages.Add(new
|
||||
{
|
||||
role = "assistant",
|
||||
content = finalText,
|
||||
tool_calls = toolCallsForHistory
|
||||
});
|
||||
|
||||
// Tool-Results sammeln
|
||||
var toolResults = new List<object>();
|
||||
foreach (var (_, (tcId, tcName, tcArgs)) in toolCallAccumulators)
|
||||
{
|
||||
// Tool-Start SSE
|
||||
await TryWriteSse(response, "tool_start", new { name = tcName, id = tcId }, sseCt);
|
||||
|
||||
// Tool ausführen
|
||||
JsonElement inputEl;
|
||||
try { inputEl = JsonSerializer.Deserialize<JsonElement>(tcArgs.ToString()); }
|
||||
catch { inputEl = JsonSerializer.Deserialize<JsonElement>("{}"); }
|
||||
|
||||
var toolResult = await _toolService.ExecuteTool(tcName, inputEl, workspace);
|
||||
var isError = toolResult.StartsWith("Fehler:") || toolResult.StartsWith("Error:");
|
||||
|
||||
// Tool-End SSE
|
||||
await TryWriteSse(response, "tool_end", new
|
||||
{
|
||||
name = tcName,
|
||||
id = tcId,
|
||||
result = toolResult,
|
||||
isError
|
||||
}, sseCt);
|
||||
|
||||
toolResults.Add(new
|
||||
{
|
||||
role = "tool",
|
||||
tool_call_id = tcId,
|
||||
content = toolResult
|
||||
});
|
||||
|
||||
// Tool-Result in DB speichern
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId, Iteration = iteration,
|
||||
Role = "tool", Type = "tool_result",
|
||||
Content = toolResult,
|
||||
ToolCallId = tcId, ToolName = tcName,
|
||||
IsError = isError
|
||||
});
|
||||
}
|
||||
|
||||
// Tool-Results in History einfügen
|
||||
foreach (var tr in toolResults)
|
||||
apiMessages.Add(tr);
|
||||
}
|
||||
|
||||
// Max Iterations erreicht
|
||||
await TryWriteSse(response, "error", new { error = "Max iterations erreicht" }, sseCt);
|
||||
await FinalizeChain(chain, usage, iteration, "max_iterations", sw);
|
||||
await TryWriteSse(response, "done", new { chainId }, sseCt);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
await FinalizeChain(chain, usage, iteration, "stopped", sw);
|
||||
await TryWriteSse(response, "done", new
|
||||
{
|
||||
model = modelString,
|
||||
iterations = iteration,
|
||||
chainId,
|
||||
durationMs = sw.ElapsedMilliseconds,
|
||||
stopReason = "stopped"
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errMsg = $"Fehler: {ex.Message}";
|
||||
await _db.SaveMessage(new MessageRecord
|
||||
{
|
||||
ChainId = chainId, Iteration = iteration,
|
||||
Role = "system", Type = "error",
|
||||
Content = errMsg, IsError = true
|
||||
});
|
||||
await TryWriteSse(response, "error", new { error = errMsg }, sseCt);
|
||||
await FinalizeChain(chain, usage, iteration, "error", sw);
|
||||
await TryWriteSse(response, "done", new { chainId }, sseCt);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Konvertiert eine Anthropic-format Message in das OpenAI-format.
|
||||
/// </summary>
|
||||
private static object ConvertMessage(object msg)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(msg);
|
||||
var el = JsonSerializer.Deserialize<JsonElement>(json);
|
||||
|
||||
if (!el.TryGetProperty("role", out var roleProp)) return null;
|
||||
var role = roleProp.GetString() ?? "";
|
||||
|
||||
if (!el.TryGetProperty("content", out var contentProp)) return null;
|
||||
|
||||
// Content als String
|
||||
if (contentProp.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var text = contentProp.GetString() ?? "";
|
||||
if (string.IsNullOrEmpty(text)) return null;
|
||||
return new { role, content = text };
|
||||
}
|
||||
|
||||
// Content als Array (Anthropic-Format mit Blöcken)
|
||||
if (contentProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
var textParts = new StringBuilder();
|
||||
var toolCalls = new List<object>();
|
||||
var toolResults = new List<object>();
|
||||
|
||||
foreach (var block in contentProp.EnumerateArray())
|
||||
{
|
||||
if (!block.TryGetProperty("type", out var typeProp)) continue;
|
||||
var blockType = typeProp.GetString() ?? "";
|
||||
|
||||
if (blockType == "text" && block.TryGetProperty("text", out var textProp))
|
||||
{
|
||||
textParts.Append(textProp.GetString());
|
||||
}
|
||||
else if (blockType == "tool_use")
|
||||
{
|
||||
// Anthropic tool_use → OpenAI tool_calls
|
||||
var tcId = block.TryGetProperty("id", out var idProp) ? idProp.GetString() : "";
|
||||
var tcName = block.TryGetProperty("name", out var nameProp) ? nameProp.GetString() : "";
|
||||
var tcInput = block.TryGetProperty("input", out var inputProp)
|
||||
? JsonSerializer.Serialize(inputProp)
|
||||
: "{}";
|
||||
toolCalls.Add(new
|
||||
{
|
||||
id = tcId,
|
||||
type = "function",
|
||||
function = new { name = tcName, arguments = tcInput }
|
||||
});
|
||||
}
|
||||
else if (blockType == "tool_result")
|
||||
{
|
||||
// Anthropic tool_result → OpenAI role: "tool"
|
||||
var trId = block.TryGetProperty("tool_use_id", out var idProp) ? idProp.GetString() : "";
|
||||
var trContent = "";
|
||||
if (block.TryGetProperty("content", out var cProp))
|
||||
{
|
||||
if (cProp.ValueKind == JsonValueKind.String)
|
||||
trContent = cProp.GetString() ?? "";
|
||||
else if (cProp.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var item in cProp.EnumerateArray())
|
||||
if (item.TryGetProperty("text", out var t)) trContent += t.GetString();
|
||||
}
|
||||
}
|
||||
toolResults.Add(new { role = "tool", tool_call_id = trId, content = trContent });
|
||||
}
|
||||
}
|
||||
|
||||
// Assistant-Message mit tool_calls
|
||||
if (toolCalls.Count > 0)
|
||||
{
|
||||
var text = textParts.ToString().Trim();
|
||||
return new
|
||||
{
|
||||
role = "assistant",
|
||||
content = string.IsNullOrEmpty(text) ? (object)null : text,
|
||||
tool_calls = toolCalls
|
||||
};
|
||||
}
|
||||
|
||||
// Tool-Results → als separate Objekte zurückgeben (werden im Caller verarbeitet)
|
||||
if (toolResults.Count > 0)
|
||||
return toolResults; // List<object> wird im Aufrufer per OfType<List<object>> erkannt
|
||||
|
||||
var combined = textParts.ToString().Trim();
|
||||
if (string.IsNullOrEmpty(combined)) return null;
|
||||
return new { role, content = combined };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task FinalizeChain(ChainRecord chain, TokenUsage usage, int iterations, string stopReason, System.Diagnostics.Stopwatch sw)
|
||||
{
|
||||
chain.InputTokens = usage.InputTokens;
|
||||
chain.OutputTokens = usage.OutputTokens;
|
||||
chain.CacheCreationTokens = usage.CacheCreationTokens;
|
||||
chain.CacheReadTokens = usage.CacheReadTokens;
|
||||
chain.Iterations = iterations;
|
||||
chain.StopReason = stopReason;
|
||||
chain.DurationMs = sw.ElapsedMilliseconds;
|
||||
await _db.UpdateChain(chain);
|
||||
}
|
||||
|
||||
private HttpClient CreateClient(string apiKey)
|
||||
{
|
||||
var client = _httpFactory.CreateClient();
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
|
||||
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
client.Timeout = TimeSpan.FromMinutes(5);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task TryWriteSse(HttpResponse response, string eventType, object data, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!ct.IsCancellationRequested)
|
||||
await AnthropicService.WriteSseEvent(response, eventType, data, ct);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user