Add multi-model panel with Fusion mode

Panel agents answer the user prompt in parallel (read-only, non-streaming);
their responses are merged into the worker's last user message as a
<panel_responses> block, and the session agent synthesizes one final
streamed answer. The RunPanel engine is built layer-ready for future MoA.

- New session fields: multi_model_mode (off/fusion), panel_agent_ids (CSV)
  with idempotent column migration
- New partial Services/AnthropicService.Panel.cs holding the RunPanel engine
- Provider-agnostic members: Anthropic RunNonStreamingToolLoop / OpenAI
  CompleteText / CLI plan-mode; all forced read-only, persisted with Tag=panel
- SSE events: panel_start, panel_member_start, panel_member_done, panel_done
- Frontend: multi-agent picker, live panel bubbles, history rendering
- Graceful degradation: panel errors fall back to worker-only

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MelekiandClaude Opus 4.8 committed 2026-06-18 12:55:06 +02:00
1 parent b23eba32b4
commit 268f11fa6d
20 files changed
+647 -35

No files matched your search

+38 -27
View File
@@ -422,6 +422,11 @@ public partial class AnthropicService
/// <summary>Ob Text-Embeddings in die Queue geschrieben werden.</summary>
public bool EnqueueEmbeddings { get; set; }
/// <summary>Ob Messages in die DB persistiert werden. false = Panel-Member:
/// die interne Arbeit (Text/Tool-Calls) wird nicht gespeichert, der Caller
/// persistiert nur die finale Proposal-Message selbst (mit [Panel: Name]-Prefix).</summary>
public bool Persist { get; set; } = true;
/// <summary>
/// Optionaler Tool-Executor. Parameter: (toolName, input, ct) → result.
/// Bei Fehler Exception werfen. Wenn null, wird Standard-Executor verwendet.
@@ -509,12 +514,13 @@ public partial class AnthropicService
{
var errBody = await httpResp.Content.ReadAsStringAsync(ct);
var errMsg = $"API {(int)httpResp.StatusCode}: {errBody}";
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true, Tag = cb.Tag
});
if (cb.Persist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "system", Type = "error",
Content = errMsg, IsError = true, Tag = cb.Tag
});
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_error", new { error = errMsg }, cb.SseCt);
break;
@@ -542,14 +548,17 @@ public partial class AnthropicService
finalText.Append(text);
assistantContent.Add(new { type = "text", text });
var msgId = await _db.SaveMessage(new MessageRecord
if (cb.Persist)
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "assistant", Type = "text",
Content = text, Tag = cb.Tag
});
if (cb.EnqueueEmbeddings)
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
var msgId = await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "assistant", Type = "text",
Content = text, Tag = cb.Tag
});
if (cb.EnqueueEmbeddings)
_embeddingBg?.Enqueue(msgId, chainId, sessionId, toolOpts.AgentId, "assistant", text);
}
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_text", new { text, agentName = cb.AgentName }, cb.SseCt);
}
@@ -563,13 +572,14 @@ public partial class AnthropicService
assistantContent.Add(new { type = "tool_use", id = toolId, name = toolName, input = inputEl });
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "assistant", Type = "tool_use",
ToolName = toolName, ToolCallId = toolId,
Content = inputEl.ToString(), Tag = cb.Tag
});
if (cb.Persist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "assistant", Type = "tool_use",
ToolName = toolName, ToolCallId = toolId,
Content = inputEl.ToString(), Tag = cb.Tag
});
ct.ThrowIfCancellationRequested();
@@ -599,13 +609,14 @@ public partial class AnthropicService
is_error = isError
});
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "user", Type = "tool_result",
ToolCallId = toolId,
Content = toolResult, IsError = isError, Tag = cb.Tag
});
if (cb.Persist)
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = baseIteration + iterations,
Role = "user", Type = "tool_result",
ToolCallId = toolId,
Content = toolResult, IsError = isError, Tag = cb.Tag
});
if (cb.SseResponse != null)
await SseHelper.TryWriteSse(cb.SseResponse, $"{cb.SsePrefix}_tool", new { name = toolName, result = TruncateToolResult(toolResult), isError, agentName = cb.AgentName }, cb.SseCt);
+266
View File
@@ -0,0 +1,266 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Gott.Models;
using Microsoft.AspNetCore.Http;
namespace Gott.Services;
public partial class AnthropicService
{
// ── Multi-Modell-Panel (Fusion) ──
//
// Mehrere Panel-Agenten ("Proposer") beantworten dieselbe Anfrage PARALLEL und
// non-streaming. Anschließend werden ihre Antworten dem Worker-Agent als
// <panel_responses>-Block vorgelegt, der daraus EINE Gesamtantwort synthetisiert
// und über die unveränderte Streaming-Pipeline ausgibt.
//
// Member laufen READ-ONLY (Anthropic: PlanMode-Filter, CLI: --permission-mode plan)
// bzw. tool-los (OpenAI), damit parallele Member keine Dateien gleichzeitig schreiben.
// Member-Arbeit wird NICHT über den Loop persistiert (Persist=false) – stattdessen
// wird pro Member genau EINE Proposal-Message mit Tag="panel" + [Panel: Name]-Prefix
// gespeichert (aus der Worker-History gefiltert, überlebt aber F5 als eigene Bubble).
private class PanelMemberResult
{
public int Index { get; set; }
public string Name { get; set; }
public string Model { get; set; }
public string Text { get; set; }
}
/// <summary>
/// Führt das Panel aus (Fusion): alle Member parallel, sammelt ihre Antworten,
/// persistiert sie (Tag="panel") und merged den Synthese-Block in die letzte
/// User-Message von <paramref name="workerMessages"/> ein. Danach streamt der Worker
/// als Synthesizer ganz normal weiter. Gibt die Anzahl beigetragener Member zurück
/// (0 = nichts injiziert → Worker antwortet regulär).
/// </summary>
public async Task<int> RunPanel(
HttpResponse response,
List<SubAgentContext> panel,
List<object> workerMessages,
string chainId,
string sessionId,
string apiToken,
CancellationToken workCt,
CancellationToken sseCt)
{
if (panel == null || panel.Count == 0) return 0;
await SseHelper.TryWriteSse(response, "panel_start", new { members = panel.Count, mode = "fusion" }, sseCt);
// Concurrency begrenzen: bei CLI-Membern sind das parallele claude-Children.
using var sem = new SemaphoreSlim(Math.Min(3, panel.Count));
var tasks = new List<Task<PanelMemberResult>>();
for (int i = 0; i < panel.Count; i++)
{
var member = panel[i];
var index = i;
tasks.Add(Task.Run(async () =>
{
await sem.WaitAsync(workCt);
try { return await RunPanelMember(response, member, workerMessages, index, chainId, sessionId, apiToken, workCt, sseCt); }
finally { sem.Release(); }
}, workCt));
}
var results = await Task.WhenAll(tasks);
var ordered = results
.Where(r => r != null && !string.IsNullOrWhiteSpace(r.Text))
.OrderBy(r => r.Index)
.ToList();
if (ordered.Count == 0)
{
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = false, members = 0 }, sseCt);
return 0;
}
// Synthese-Block bauen und in die letzte User-Message einmergen.
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("---");
sb.AppendLine("[Multi-Modell-Panel] Mehrere unabhängige KI-Agenten haben parallel auf die obige Anfrage geantwortet. "
+ "Deine Aufgabe: Synthetisiere daraus EINE optimale Gesamtantwort für den User. Nimm die stärksten Argumente, "
+ "korrektesten Fakten und besten Ideen aus allen Beiträgen, korrigiere erkennbare Fehler und vermeide bloßes "
+ "Aneinanderreihen. Widersprechen sich Beiträge, wäge ab und entscheide begründet. Antworte direkt und natürlich "
+ "dem User (du musst das Panel nicht erwähnen). Du kannst deine eigenen Tools nutzen, um zu prüfen oder die Aufgabe abzuschließen.");
sb.AppendLine();
sb.AppendLine("<panel_responses>");
foreach (var r in ordered)
{
sb.AppendLine();
sb.AppendLine($"### Antwort von {r.Name} ({r.Model}):");
sb.AppendLine(r.Text.Trim());
}
sb.AppendLine("</panel_responses>");
MergePanelIntoLastUserText(workerMessages, sb.ToString());
await SseHelper.TryWriteSse(response, "panel_done", new { synthesizing = true, members = ordered.Count }, sseCt);
return ordered.Count;
}
/// <summary>
/// Führt einen einzelnen Panel-Member aus (Provider-Weiche, read-only/tool-los),
/// persistiert seine Proposal-Message (Tag="panel") und sendet panel_member_* SSE-Events.
/// Fängt Member-Fehler ab (ein fehlerhafter Member darf das Panel nicht killen).
/// </summary>
private async Task<PanelMemberResult> RunPanelMember(
HttpResponse response,
SubAgentContext member,
List<object> workerMessages,
int index,
string chainId,
string sessionId,
string apiToken,
CancellationToken workCt,
CancellationToken sseCt)
{
var name = member.Agent?.Name ?? $"Panel-Agent {index + 1}";
await SseHelper.TryWriteSse(response, "panel_member_start", new { index, name, model = member.ModelId }, sseCt);
var memberMessages = new List<object>(workerMessages); // eigene Kopie pro Member
string text;
try
{
if (!string.IsNullOrEmpty(member.Provider) && _openAiService != null)
{
// OpenAI-kompatibler Member: tool-los (CompleteText), Proposer aus Kontext.
var systemText = ExtractSystemPromptText(member.SystemPrompt);
text = await _openAiService.CompleteText(member.ModelId, systemText, memberMessages, apiToken);
}
else if (string.IsNullOrEmpty(apiToken) && _cliService != null && _cliService.IsAvailable)
{
// Kein API-Key → CLI-Member, read-only (Plan-Modus: nur Read/Glob/Grep).
text = await RunPanelMemberViaCli(member, memberMessages, workCt);
}
else
{
// Anthropic-API-Member: read-only (PlanMode) + NICHT persistieren (Caller persistiert einmal).
var memberUsage = new TokenUsage();
var (t, _) = await RunNonStreamingToolLoop(
member.ModelId, member.SystemPrompt,
new ToolPayloadOptions
{
Workspace = member.Workspace, AgentId = member.AgentId,
UseMemory = member.UseMemory, WebSearchVersion = member.WebSearchVersion,
ToolChoice = "auto", PlanMode = true
},
memberMessages, chainId, sessionId, apiToken, memberUsage,
0, 12, workCt,
new NonStreamingLoopCallbacks { Persist = false },
member.Effort);
text = t;
}
}
catch (OperationCanceledException) { throw; }
catch (Exception ex) { text = $"[Fehler bei {name}: {ex.Message}]"; }
text = (text ?? "").Trim();
// Proposal als EINE Message persistieren: Tag="panel" → aus Worker-History gefiltert,
// [Panel: Name]-Prefix → Label beim History-Rendering. Iteration=index+1 → stabile Reihenfolge.
if (!string.IsNullOrEmpty(text))
{
await _db.SaveMessage(new MessageRecord
{
ChainId = chainId, Iteration = index + 1,
Role = "assistant", Type = "text",
Content = $"[Panel: {name}]\n{text}", Tag = "panel"
});
}
await SseHelper.TryWriteSse(response, "panel_member_done",
new { index, name, model = member.ModelId, text }, sseCt);
return new PanelMemberResult { Index = index, Name = name, Model = member.ModelId, Text = text };
}
/// <summary>
/// Führt einen Panel-Member über die Claude CLI aus (read-only via Plan-Modus).
/// Sammelt nur den Text, persistiert nichts und sendet keine Text-Deltas
/// (parallele Member → Deltas würden interleaven; der volle Text kommt via panel_member_done).
/// </summary>
private async Task<string> RunPanelMemberViaCli(SubAgentContext member, List<object> memberMessages, CancellationToken workCt)
{
if (_cliService == null || !_cliService.IsAvailable) return "";
var systemText = ExtractSystemPromptText(member.SystemPrompt);
var prompt = ExtractLastUserText(memberMessages) ?? "";
var historyContext = BuildHistoryContext(memberMessages);
if (!string.IsNullOrEmpty(historyContext))
prompt = $"<conversation_history>\n{historyContext}\n</conversation_history>\n\n<current_request>\n{prompt}\n</current_request>";
var fullText = new StringBuilder();
await _cliService.ChatStreamingAsync(
prompt,
evt =>
{
if (evt.Type == CliEventType.TextDelta)
fullText.Append(evt.Text);
return Task.CompletedTask;
},
modelId: member.ModelId,
systemPrompt: systemText,
workspace: member.Workspace,
hasWorkspace: !string.IsNullOrEmpty(member.Workspace),
planMode: true,
effort: member.Effort,
cancellationToken: workCt
);
return fullText.ToString();
}
/// <summary>Extrahiert den System-Prompt-Text aus den List&lt;object&gt;-Blöcken eines SubAgentContext.</summary>
private static string ExtractSystemPromptText(List<object> systemPrompt)
{
if (systemPrompt == null) return "";
var parts = new List<string>();
foreach (var block in systemPrompt)
{
var json = JsonSerializer.Serialize(block);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("text", out var t))
parts.Add(t.GetString() ?? "");
}
return string.Join("\n\n", parts);
}
/// <summary>
/// Hängt den Panel-Synthese-Block an den BESTEHENDEN Text-Block der letzten User-Message an.
/// Wichtig: kein zweiter Text-Block – sonst sieht der CLI-Worker (ExtractLastUserText liefert
/// nur den ersten Text-Block) die Synthese-Anweisung nicht. So funktioniert es für API, CLI und OpenAI.
/// </summary>
private static void MergePanelIntoLastUserText(List<object> messages, string panelBlock)
{
for (int i = messages.Count - 1; i >= 0; i--)
{
if (messages[i] is Dictionary<string, object> d &&
d.TryGetValue("role", out var r) && (r as string) == "user" &&
d.TryGetValue("content", out var c) && c is List<object> contentList)
{
for (int j = 0; j < contentList.Count; j++)
{
var json = JsonSerializer.Serialize(contentList[j]);
var el = JsonSerializer.Deserialize<JsonElement>(json);
if (el.TryGetProperty("type", out var t) && t.GetString() == "text")
{
var orig = el.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : "";
contentList[j] = new { type = "text", text = orig + "\n\n" + panelBlock };
return;
}
}
// Kein Text-Block vorhanden → einen anhängen.
contentList.Add(new { type = "text", text = panelBlock });
return;
}
}
}
}
+18
View File
@@ -143,6 +143,22 @@ public class ChatService
var finalizerContext = await BuildSubAgentContext(
session.FinalizerAgentId, sessionId, workspace, session, user);
// Multi-Modell-Panel (Fusion): Panel-Agenten (Proposer) bauen, falls aktiviert.
// Jeder Panel-Agent ist ein vollwertiger SubAgentContext (Modell/Provider/Key/Prompt/Effort).
var panelContexts = new List<SubAgentContext>();
if (!string.IsNullOrEmpty(session.MultiModelMode) && session.MultiModelMode != "off"
&& !string.IsNullOrWhiteSpace(session.PanelAgentIds))
{
var panelIds = session.PanelAgentIds
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Distinct();
foreach (var pid in panelIds)
{
var pc = await BuildSubAgentContext(pid, sessionId, workspace, session, user);
if (pc != null) panelContexts.Add(pc);
}
}
if (preparerContext != null)
{
preparerContext.AllowUserChoice = preparerContext.Agent?.AllowUserChoice ?? false;
@@ -204,6 +220,8 @@ Für welche Plattform soll die App entwickelt werden? Web, Mobile oder Desktop?
Preparer = preparerContext,
Review = reviewContext,
Finalizer = finalizerContext,
MultiModelMode = session.MultiModelMode,
PanelAgents = panelContexts,
PlanMode = planMode
};
}
+7 -3
View File
@@ -12,7 +12,7 @@ public partial class DatabaseService
{
// ── Sessions ──
public async Task<SessionRecord> CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null, string preparerAgentId = null)
public async Task<SessionRecord> CreateSession(string userId, string name = null, string description = null, string image = null, string imageSmall = null, string agentId = null, string workspace = null, string context = null, string customCommands = null, double avatarScale = 1.0, double avatarOffsetX = 0, double avatarOffsetY = 0, string reviewAgentId = null, int maxReviewIterations = 3, string finalizerAgentId = null, string preparerAgentId = null, string multiModelMode = null, string panelAgentIds = null)
{
using var conn = CreateConnection();
var now = DateTime.UtcNow.ToString("o");
@@ -35,12 +35,14 @@ public partial class DatabaseService
MaxReviewIterations = maxReviewIterations,
FinalizerAgentId = finalizerAgentId,
PreparerAgentId = preparerAgentId,
MultiModelMode = multiModelMode,
PanelAgentIds = panelAgentIds,
CreatedAt = now,
UpdatedAt = now
};
await conn.ExecuteAsync(@"
INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, created_at, updated_at)
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @CreatedAt, @UpdatedAt)
INSERT INTO sessions (session_id, user_id, name, description, image, image_small, agent_id, workspace, context, custom_commands, avatar_scale, avatar_offset_x, avatar_offset_y, review_agent_id, max_review_iterations, finalizer_agent_id, preparer_agent_id, multi_model_mode, panel_agent_ids, created_at, updated_at)
VALUES (@SessionId, @UserId, @Name, @Description, @Image, @ImageSmall, @AgentId, @Workspace, @Context, @CustomCommands, @AvatarScale, @AvatarOffsetX, @AvatarOffsetY, @ReviewAgentId, @MaxReviewIterations, @FinalizerAgentId, @PreparerAgentId, @MultiModelMode, @PanelAgentIds, @CreatedAt, @UpdatedAt)
", session);
return session;
}
@@ -155,6 +157,8 @@ public partial class DatabaseService
if (body.TryGetProperty("maxReviewIterations", out var mri)) { sets.Add("max_review_iterations = @maxReviewIterations"); p.Add("maxReviewIterations", mri.GetInt32()); }
if (body.TryGetProperty("finalizerAgentId", out var fa)) { sets.Add("finalizer_agent_id = @finalizerAgentId"); p.Add("finalizerAgentId", fa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : fa.GetString()); }
if (body.TryGetProperty("preparerAgentId", out var pa)) { sets.Add("preparer_agent_id = @preparerAgentId"); p.Add("preparerAgentId", pa.ValueKind == System.Text.Json.JsonValueKind.Null ? null : pa.GetString()); }
if (body.TryGetProperty("multiModelMode", out var mmm)) { sets.Add("multi_model_mode = @multiModelMode"); p.Add("multiModelMode", mmm.ValueKind == System.Text.Json.JsonValueKind.Null ? null : mmm.GetString()); }
if (body.TryGetProperty("panelAgentIds", out var pai)) { sets.Add("panel_agent_ids = @panelAgentIds"); p.Add("panelAgentIds", pai.ValueKind == System.Text.Json.JsonValueKind.Null ? null : pai.GetString()); }
if (body.TryGetProperty("unityMcpUrl", out var umu)) { sets.Add("unity_mcp_url = @unityMcpUrl"); p.Add("unityMcpUrl", umu.ValueKind == System.Text.Json.JsonValueKind.Null ? null : umu.GetString()); }
if (body.TryGetProperty("unityMcpToken", out var umt)) { sets.Add("unity_mcp_token = @unityMcpToken"); p.Add("unityMcpToken", umt.ValueKind == System.Text.Json.JsonValueKind.Null ? null : umt.GetString()); }
+2
View File
@@ -198,6 +198,8 @@ public partial class DatabaseService
TryAddColumn(conn, "messages", "tag", "TEXT");
TryAddColumn(conn, "agents", "effort", "TEXT NOT NULL DEFAULT ''");
TryAddColumn(conn, "chains", "effort", "TEXT NOT NULL DEFAULT ''");
TryAddColumn(conn, "sessions", "multi_model_mode", "TEXT");
TryAddColumn(conn, "sessions", "panel_agent_ids", "TEXT");
// Migration: 1M-Suffix entfernen (Mai 2026). Seit März 2026 ist 1M-Context Default
// bei Opus 4.6/4.7/4.8 + Sonnet 4.6 – kein "[1m]"-Suffix mehr nötig.
+50
View File
@@ -1087,6 +1087,56 @@ public class OpenAiService
/// Non-Streaming Chat für Telegram, Cronjobs und send_to_session.
/// Gibt den finalen Antworttext zurück. Unterstützt Tool-Calling (agentic loop).
/// </summary>
/// <summary>
/// Minimaler Single-Shot-Completion-Call: KEINE Tools, KEINE Persistenz, KEINE Chain.
/// Für Panel-Member (Fusion) – liefert nur den Antworttext zurück. So leckt nichts
/// ungetaggt in die Worker-History und es entstehen keine Seiteneffekte (Schreiben/Chains).
/// </summary>
public async Task<string> CompleteText(string modelId, string systemPromptText, List<object> messages, string apiToken)
{
var resolved = ResolveProviderConfig(modelId, apiToken);
if (resolved == null)
throw new Exception($"Unbekannter Provider: '{ParseModelString(modelId).provider}'");
var (provider, resolvedModelId, providerConfig) = resolved.Value;
var apiMessages = BuildApiMessages(systemPromptText, messages);
var client = CreateClient(providerConfig.ApiKey);
var maxTokens = provider == "minimax" ? 32768 : 16384;
var payload = new Dictionary<string, object>
{
["model"] = resolvedModelId,
["messages"] = apiMessages,
["stream"] = false,
["max_tokens"] = maxTokens
};
if (provider == "nvidia" && IsNvidiaThinkingModel(resolvedModelId))
payload["chat_template_kwargs"] = new { thinking = true };
if (provider == "minimax")
{
payload["temperature"] = 1.0;
payload["top_p"] = 0.95;
}
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 });
var respBody = await httpResp.Content.ReadAsStringAsync();
if (!httpResp.IsSuccessStatusCode)
throw new Exception($"OpenAI API {(int)httpResp.StatusCode}: {respBody}");
var resp = JsonSerializer.Deserialize<JsonElement>(respBody);
if (!resp.TryGetProperty("choices", out var choices) || choices.ValueKind != JsonValueKind.Array || choices.GetArrayLength() == 0)
return "";
var choice = choices[0];
if (!choice.TryGetProperty("message", out var message) || message.ValueKind != JsonValueKind.Object)
return "";
if (message.TryGetProperty("content", out var contentProp) && contentProp.ValueKind == JsonValueKind.String)
return contentProp.GetString() ?? "";
return "";
}
public async Task<string> ChatNonStreaming(
ChatContext ctx,
List<object> messages,