Files
AX-Copilot-Codex/src/AxCopilot/Services/LlmService.ToolUse.cs
lacvet 6e99837a4c
Some checks are pending
Release Gate / gate (push) Waiting to run
vLLM 도구 호출 스트리밍 실행기와 코워크 루프 실시간 소비 구조 추가
- LlmService에 tool-use 전용 streaming event API를 추가하고 OpenAI vLLM IBM 경로의 partial tool_call 조립을 event 기반으로 재구성함
- Cowork/Code 루프가 streaming event를 직접 소비하도록 바꿔 도구 호출 감지와 진행 표시를 더 빠르게 갱신함
- read-only 도구 조기 실행이 기존 loop와 실제로 이어지도록 정리하고 최종 실행에서는 prefetch 결과를 재사용함
- README와 DEVELOPMENT 문서를 2026-04-08 11:31(KST) 기준으로 갱신함

검증
- dotnet build src/AxCopilot/AxCopilot.csproj -c Release -v minimal -p:OutputPath=bin\verify\ -p:IntermediateOutputPath=obj\verify\
- 경고 0 / 오류 0
2026-04-08 16:58:11 +09:00

1432 lines
60 KiB
C#

using System.Net.Http;
using System.Runtime.CompilerServices;
using System.IO;
using System.Text;
using System.Text.Json;
using AxCopilot.Models;
using AxCopilot.Services.Agent;
namespace AxCopilot.Services;
/// <summary>
/// LlmService의 Function Calling (tool_use) 확장.
/// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다.
/// 기존 SendAsync/StreamAsync는 변경하지 않고, 에이전트 전용 메서드를 추가합니다.
/// </summary>
public partial class LlmService
{
/// <summary>LLM 응답에서 파싱된 컨텐츠 블록.</summary>
public class ContentBlock
{
public string Type { get; init; } = "text"; // "text" | "tool_use"
public string Text { get; init; } = ""; // text 타입일 때
public string ToolName { get; init; } = ""; // tool_use 타입일 때
public string ToolId { get; init; } = ""; // tool_use ID
public JsonElement? ToolInput { get; init; } // tool_use 파라미터
public string? ResolvedToolName { get; set; }
public Task<ToolPrefetchResult?>? PrefetchedExecutionTask { get; set; }
}
public sealed record ToolPrefetchResult(
Agent.ToolResult Result,
long ElapsedMilliseconds,
string? ResolvedToolName = null);
public enum ToolStreamEventKind
{
TextDelta,
ToolCallReady,
Completed
}
public sealed record ToolStreamEvent(
ToolStreamEventKind Kind,
string Text = "",
ContentBlock? ToolCall = null);
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
/// <param name="forceToolCall">
/// true이면 <c>tool_choice: "required"</c>를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다.
/// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에 사용하세요.
/// Claude/Gemini는 지원하지 않으므로 vLLM/Ollama/OpenAI 계열에만 적용됩니다.
/// </param>
public async Task<List<ContentBlock>> SendWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
CancellationToken ct = default,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
{
var activeService = ResolveService();
EnsureOperationModeAllowsLlmService(activeService);
return NormalizeServiceName(activeService) switch
{
"sigmoid" => await SendSigmoidWithToolsAsync(messages, tools, ct),
"gemini" => await SendGeminiWithToolsAsync(messages, tools, ct),
"ollama" or "vllm" => await SendOpenAiWithToolsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync),
_ => throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling을 지원하지 않습니다.")
};
}
public async IAsyncEnumerable<ToolStreamEvent> StreamWithToolsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null,
[EnumeratorCancellation] CancellationToken ct = default)
{
var activeService = ResolveService();
EnsureOperationModeAllowsLlmService(activeService);
switch (NormalizeServiceName(activeService))
{
case "ollama":
case "vllm":
await foreach (var evt in StreamOpenAiToolEventsAsync(messages, tools, ct, forceToolCall, prefetchToolCallAsync).WithCancellation(ct))
yield return evt;
yield break;
case "sigmoid":
foreach (var block in await SendSigmoidWithToolsAsync(messages, tools, ct))
{
if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, block.Text);
else if (block.Type == "tool_use")
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block);
}
yield return new ToolStreamEvent(ToolStreamEventKind.Completed);
yield break;
case "gemini":
foreach (var block in await SendGeminiWithToolsAsync(messages, tools, ct))
{
if (block.Type == "text" && !string.IsNullOrWhiteSpace(block.Text))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, block.Text);
else if (block.Type == "tool_use")
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: block);
}
yield return new ToolStreamEvent(ToolStreamEventKind.Completed);
yield break;
default:
throw new NotSupportedException($"서비스 '{activeService}'는 아직 Function Calling 스트리밍을 지원하지 않습니다.");
}
}
/// <summary>도구 실행 결과를 LLM에 피드백하기 위한 메시지를 생성합니다.</summary>
public static ChatMessage CreateToolResultMessage(string toolId, string toolName, string result)
{
// Claude: role=user, content=[{type:"tool_result", tool_use_id, content}]
// 내부적으로는 JSON으로 인코딩하여 Content에 저장
var payload = JsonSerializer.Serialize(new
{
type = "tool_result",
tool_use_id = toolId,
tool_name = toolName,
content = result
});
return new ChatMessage
{
Role = "user",
Content = payload,
Timestamp = DateTime.Now,
};
}
// ─── Claude Function Calling ──────────────────────────────────────
private async Task<List<ContentBlock>> SendSigmoidWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct)
{
var apiKey = ResolveApiKeyForService("sigmoid");
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Claude API 키가 설정되지 않았습니다.");
var body = BuildSigmoidToolBody(messages, tools);
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, $"https://{SigmoidApiHost}/v1/messages");
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
req.Headers.Add("x-api-key", apiKey);
req.Headers.Add(SigmoidApiVersionHeader, SigmoidApiVersion);
using var resp = await _http.SendAsync(req, ct);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
throw new HttpRequestException(ClassifyHttpError(resp, errBody));
}
var respJson = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement;
// 토큰 사용량
if (root.TryGetProperty("usage", out var usage))
TryParseSigmoidUsageFromElement(usage);
// 컨텐츠 블록 파싱
var blocks = new List<ContentBlock>();
if (root.TryGetProperty("content", out var content))
{
foreach (var block in content.EnumerateArray())
{
var type = block.TryGetProperty("type", out var tp) ? tp.GetString() : "";
if (type == "text")
{
blocks.Add(new ContentBlock
{
Type = "text",
Text = block.TryGetProperty("text", out var txt) ? txt.GetString() ?? "" : ""
});
}
else if (type == "tool_use")
{
blocks.Add(new ContentBlock
{
Type = "tool_use",
ToolName = block.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
ToolId = block.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "",
ToolInput = block.TryGetProperty("input", out var inp) ? inp.Clone() : null
});
}
}
}
return blocks;
}
private object BuildSigmoidToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
foreach (var m in messages)
{
if (m.Role == "system") continue;
// tool_result 메시지인지 확인
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
msgs.Add(new
{
role = "user",
content = new object[]
{
new
{
type = "tool_result",
tool_use_id = root.TryGetProperty("tool_use_id", out var tuid) ? tuid.GetString() : "",
content = root.TryGetProperty("content", out var tcont) ? tcont.GetString() : ""
}
}
});
continue;
}
catch { /* 파싱 실패시 일반 메시지로 처리 */ }
}
// assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프)
if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
if (!doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr)) throw new Exception();
var contentList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : "";
if (bType == "text")
contentList.Add(new { type = "text", text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" });
else if (bType == "tool_use")
contentList.Add(new
{
type = "tool_use",
id = b.TryGetProperty("id", out var bid) ? bid.GetString() ?? "" : "",
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
input = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
});
}
msgs.Add(new { role = "assistant", content = contentList });
continue;
}
catch { /* 파싱 실패시 일반 메시지로 처리 */ }
}
// Claude Vision: 이미지가 있으면 content를 배열로 변환
if (m.Images?.Count > 0 && m.Role == "user")
{
var contentParts = new List<object>();
foreach (var img in m.Images)
contentParts.Add(new { type = "image", source = new { type = "base64", media_type = img.MimeType, data = img.Base64 } });
contentParts.Add(new { type = "text", text = m.Content });
msgs.Add(new { role = m.Role, content = contentParts });
}
else
{
msgs.Add(new { role = m.Role, content = m.Content });
}
}
// 도구 정의
var toolDefs = tools.Select(t => new
{
name = t.Name,
description = t.Description,
input_schema = new
{
type = "object",
properties = t.Parameters.Properties.ToDictionary(
kv => kv.Key,
kv => BuildPropertySchema(kv.Value, false)),
required = t.Parameters.Required
}
}).ToArray();
// 시스템 프롬프트
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
var activeModel = ResolveModel();
if (!string.IsNullOrEmpty(systemPrompt))
{
return new
{
model = activeModel,
max_tokens = ResolveOpenAiCompatibleMaxTokens(),
temperature = llm.Temperature,
system = systemPrompt,
messages = msgs,
tools = toolDefs,
stream = false
};
}
return new
{
model = activeModel,
max_tokens = ResolveOpenAiCompatibleMaxTokens(),
temperature = llm.Temperature,
messages = msgs,
tools = toolDefs,
stream = false
};
}
// ─── Gemini Function Calling ───────────────────────────────────────
private async Task<List<ContentBlock>> SendGeminiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct)
{
var llm = _settings.Settings.Llm;
var apiKey = ResolveApiKeyForService("gemini");
if (string.IsNullOrEmpty(apiKey))
throw new InvalidOperationException("Gemini API 키가 설정되지 않았습니다.");
var activeModel = ResolveModel();
var body = BuildGeminiToolBody(messages, tools);
var url = $"https://generativelanguage.googleapis.com/v1beta/models/{activeModel}:generateContent?key={apiKey}";
var json = JsonSerializer.Serialize(body);
using var content = new StringContent(json, Encoding.UTF8, "application/json");
using var resp = await _http.PostAsync(url, content, ct);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
throw new HttpRequestException($"Gemini API 오류 ({resp.StatusCode}): {errBody}");
}
var respJson = await resp.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(respJson);
var root = doc.RootElement;
TryParseGeminiUsage(root);
var blocks = new List<ContentBlock>();
if (root.TryGetProperty("candidates", out var candidates) && candidates.GetArrayLength() > 0)
{
var firstCandidate = candidates[0];
if (firstCandidate.TryGetProperty("content", out var contentObj) &&
contentObj.TryGetProperty("parts", out var parts))
{
foreach (var part in parts.EnumerateArray())
{
if (part.TryGetProperty("text", out var text))
{
blocks.Add(new ContentBlock { Type = "text", Text = text.GetString() ?? "" });
}
else if (part.TryGetProperty("functionCall", out var fc))
{
blocks.Add(new ContentBlock
{
Type = "tool_use",
ToolName = fc.TryGetProperty("name", out var fcName) ? fcName.GetString() ?? "" : "",
ToolId = Guid.NewGuid().ToString("N")[..12],
ToolInput = fc.TryGetProperty("args", out var a) ? a.Clone() : null
});
}
}
}
}
return blocks;
}
private object BuildGeminiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
{
var contents = new List<object>();
foreach (var m in messages)
{
if (m.Role == "system") continue;
var role = m.Role == "assistant" ? "model" : "user";
// tool_result 메시지 처리
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
var toolName = root.TryGetProperty("tool_name", out var tn) ? tn.GetString() ?? "" : "";
var toolContent = root.TryGetProperty("content", out var tc) ? tc.GetString() ?? "" : "";
contents.Add(new
{
role = "function",
parts = new object[]
{
new
{
functionResponse = new
{
name = toolName,
response = new { result = toolContent }
}
}
}
});
continue;
}
catch { }
}
// assistant 메시지에 tool_use 블록이 포함된 경우 (에이전트 루프)
if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
if (doc.RootElement.TryGetProperty("_tool_use_blocks", out var blocksArr))
{
var parts = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.TryGetProperty("type", out var bt) ? bt.GetString() : "";
if (bType == "text")
parts.Add(new { text = b.TryGetProperty("text", out var tx) ? tx.GetString() ?? "" : "" });
else if (bType == "tool_use")
parts.Add(new
{
functionCall = new
{
name = b.TryGetProperty("name", out var nm) ? nm.GetString() ?? "" : "",
args = b.TryGetProperty("input", out var inp) ? (object)inp.Clone() : new { }
}
});
}
contents.Add(new { role = "model", parts });
continue;
}
}
catch { }
}
// Gemini Vision: 이미지가 있으면 parts에 inlineData 추가
if (m.Images?.Count > 0 && m.Role == "user")
{
var imgParts = new List<object> { new { text = m.Content } };
foreach (var img in m.Images)
imgParts.Add(new { inlineData = new { mimeType = img.MimeType, data = img.Base64 } });
contents.Add(new { role, parts = imgParts });
}
else
{
contents.Add(new { role, parts = new[] { new { text = m.Content } } });
}
}
// 도구 정의 (Gemini function_declarations 형식)
var funcDecls = tools.Select(t => new
{
name = t.Name,
description = t.Description,
parameters = new
{
type = "OBJECT",
properties = t.Parameters.Properties.ToDictionary(
kv => kv.Key,
kv => BuildPropertySchema(kv.Value, true)),
required = t.Parameters.Required
}
}).ToArray();
var systemInstruction = messages.FirstOrDefault(m => m.Role == "system");
var body = new Dictionary<string, object>
{
["contents"] = contents,
["tools"] = new[] { new { function_declarations = funcDecls } },
["generationConfig"] = new
{
temperature = ResolveTemperature(),
maxOutputTokens = _settings.Settings.Llm.MaxContextTokens,
}
};
if (systemInstruction != null)
{
body["systemInstruction"] = new
{
parts = new[] { new { text = systemInstruction.Content } }
};
}
return body;
}
// ─── OpenAI Compatible (Ollama / vLLM) Function Calling ──────────
private async Task<List<ContentBlock>> SendOpenAiWithToolsAsync(
List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, CancellationToken ct,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
{
var activeService = ResolveService();
// 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo)
var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo();
var endpoint = string.IsNullOrEmpty(resolvedEp)
? ResolveEndpointForService(activeService)
: resolvedEp;
var registered = GetActiveRegisteredModel();
var isIbmDeployment = UsesIbmDeploymentChatApi(activeService, registered, endpoint);
var body = isIbmDeployment
? BuildIbmToolBody(messages, tools, forceToolCall)
: BuildOpenAiToolBody(messages, tools, forceToolCall);
string url;
if (isIbmDeployment)
url = BuildIbmDeploymentChatUrl(endpoint, stream: true);
else if (activeService.ToLowerInvariant() == "ollama")
url = endpoint.TrimEnd('/') + "/api/chat";
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
// CP4D 또는 Bearer 인증 적용
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}");
if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400)
{
LogService.Warn("[ToolUse] IBM 배포형 경로에서 tool_choice가 거부되어 대체 강제 전략으로 재시도합니다.");
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
};
await ApplyAuthHeaderAsync(retryReq, ct);
using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (retryResp.IsSuccessStatusCode)
return await ReadOpenAiToolBlocksFromStreamAsync(retryResp, true, prefetchToolCallAsync, ct);
}
// 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도
if ((int)resp.StatusCode == 400)
throw new ToolCallNotSupportedException(
$"{activeService} API 오류 ({resp.StatusCode}): {detail}");
throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
}
return await ReadOpenAiToolBlocksFromStreamAsync(resp, isIbmDeployment, prefetchToolCallAsync, ct);
}
/// <summary>
/// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다.
/// 지원 패턴:
/// 1. &lt;tool_call&gt;{"name":"...", "arguments":{...}}&lt;/tool_call&gt;
/// 2. Qwen3 &lt;tool_call&gt;\n{"name":"...", "arguments":{...}}\n&lt;/tool_call&gt;
/// 3. 여러 건의 연속 tool_call 태그
/// </summary>
private static List<ContentBlock> TryExtractToolCallsFromText(string text)
{
var results = new List<ContentBlock>();
if (string.IsNullOrWhiteSpace(text)) return results;
// 패턴 1: <tool_call>...</tool_call> 태그 (Qwen 계열 기본 출력)
var tagPattern = new System.Text.RegularExpressions.Regex(
@"<\s*tool_call\s*>\s*(\{[\s\S]*?\})\s*<\s*/\s*tool_call\s*>",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in tagPattern.Matches(text))
{
var block = TryParseToolCallJson(m.Groups[1].Value);
if (block != null) results.Add(block);
}
// 패턴 2: ✿FUNCTION✿ 또는 <|tool_call|> (일부 Qwen 변형)
if (results.Count == 0)
{
var fnPattern = new System.Text.RegularExpressions.Regex(
@"✿FUNCTION✿\s*(\w+)\s*\n\s*(\{[\s\S]*?\})\s*(?:✿|$)",
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
foreach (System.Text.RegularExpressions.Match m in fnPattern.Matches(text))
{
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
}
}
// 패턴 3: JSON 객체가 직접 출력된 경우 ({"name":"tool_name","arguments":{...}})
if (results.Count == 0)
{
var jsonPattern = new System.Text.RegularExpressions.Regex(
@"\{\s*""name""\s*:\s*""(\w+)""\s*,\s*""arguments""\s*:\s*(\{[\s\S]*?\})\s*\}");
foreach (System.Text.RegularExpressions.Match m in jsonPattern.Matches(text))
{
var block = TryParseToolCallJsonWithName(m.Groups[1].Value, m.Groups[2].Value);
if (block != null) results.Add(block);
}
}
return results;
}
/// <summary>{"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환.</summary>
private static ContentBlock? TryParseToolCallJson(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
var name = root.TryGetProperty("name", out var n) ? n.GetString() ?? "" : "";
if (string.IsNullOrEmpty(name)) return null;
JsonElement? args = null;
if (root.TryGetProperty("arguments", out var a))
args = a.Clone();
else if (root.TryGetProperty("parameters", out var p))
args = p.Clone();
return new ContentBlock
{
Type = "tool_use",
ToolName = name,
ToolId = $"text_fc_{Guid.NewGuid():N}"[..16],
ToolInput = args,
};
}
catch { return null; }
}
/// <summary>이름과 arguments JSON이 별도로 주어진 경우.</summary>
private static ContentBlock? TryParseToolCallJsonWithName(string name, string argsJson)
{
if (string.IsNullOrWhiteSpace(name)) return null;
try
{
using var doc = JsonDocument.Parse(argsJson);
return new ContentBlock
{
Type = "tool_use",
ToolName = name.Trim(),
ToolId = $"text_fc_{Guid.NewGuid():N}"[..16],
ToolInput = doc.RootElement.Clone(),
};
}
catch { return null; }
}
private object BuildOpenAiToolBody(List<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools, bool forceToolCall = false)
{
var llm = _settings.Settings.Llm;
var msgs = new List<object>();
foreach (var m in messages)
{
// tool_result 메시지 → OpenAI tool 응답 형식
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
msgs.Add(new
{
role = "tool",
tool_call_id = root.GetProperty("tool_use_id").GetString(),
content = root.GetProperty("content").GetString(),
});
continue;
}
catch { }
}
// assistant 메시지에 tool_use 블록이 포함된 경우
if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
var textContent = "";
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.GetProperty("type").GetString();
if (bType == "text")
textContent = b.GetProperty("text").GetString() ?? "";
else if (bType == "tool_use")
{
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new
{
id = b.GetProperty("id").GetString() ?? "",
type = "function",
function = new
{
name = b.GetProperty("name").GetString() ?? "",
arguments = argsJson,
}
});
}
}
msgs.Add(new
{
role = "assistant",
content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent,
tool_calls = toolCallsList,
});
continue;
}
catch { }
}
// ── 이미지 첨부 (Vision) ──
if (m.Role == "user" && m.Images?.Count > 0)
{
var contentParts = new List<object>();
foreach (var img in m.Images)
contentParts.Add(new { type = "image_url", image_url = new { url = $"data:{img.MimeType};base64,{img.Base64}" } });
contentParts.Add(new { type = "text", text = m.Content });
msgs.Add(new { role = m.Role, content = contentParts });
}
else
{
msgs.Add(new { role = m.Role, content = m.Content });
}
}
// OpenAI 도구 정의
var toolDefs = tools.Select(t =>
{
// parameters 객체: required가 비어있으면 생략 (일부 Ollama 버전 호환)
var paramDict = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = t.Parameters.Properties.ToDictionary(
kv => kv.Key,
kv => BuildPropertySchema(kv.Value, false)),
};
if (t.Parameters.Required is { Count: > 0 })
paramDict["required"] = t.Parameters.Required;
return new
{
type = "function",
function = new
{
name = t.Name,
description = t.Description,
parameters = paramDict,
}
};
}).ToArray();
var activeService = ResolveService();
var activeModel = ResolveModel();
var executionPolicy = GetActiveExecutionPolicy();
var isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase);
if (isOllama)
{
return new
{
model = activeModel,
messages = msgs,
tools = toolDefs,
stream = false,
options = new { temperature = ResolveToolTemperature() }
};
}
var body = new Dictionary<string, object?>
{
["model"] = activeModel,
["messages"] = msgs,
["tools"] = toolDefs,
["stream"] = true,
["temperature"] = ResolveToolTemperature(),
["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(),
["parallel_tool_calls"] = executionPolicy.EnableParallelReadBatch,
};
// tool_choice: "required" — 모델이 반드시 도구를 호출하도록 강제
// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에서만 사용 (chatty 모델 대응)
if (forceToolCall)
body["tool_choice"] = "required";
var effort = ResolveReasoningEffort();
if (!string.IsNullOrWhiteSpace(effort))
body["reasoning_effort"] = effort;
return body;
}
/// <summary>IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음.</summary>
private object BuildIbmToolBody(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
bool forceToolCall = false,
bool useToolChoice = true)
{
var executionPolicy = GetActiveExecutionPolicy();
var strictToolOnlyDirective =
forceToolCall &&
string.Equals(executionPolicy.Key, "tool_call_strict", StringComparison.OrdinalIgnoreCase);
var msgs = new List<object>();
// 시스템 프롬프트
var systemPrompt = messages.FirstOrDefault(m => m.Role == "system")?.Content ?? _systemPrompt;
if (!string.IsNullOrWhiteSpace(systemPrompt))
msgs.Add(new { role = "system", content = systemPrompt });
foreach (var m in messages)
{
if (m.Role == "system") continue;
// tool_result → OpenAI role:"tool" 형식 (watsonx /text/chat 지원)
if (m.Role == "user" && m.Content.StartsWith("{\"type\":\"tool_result\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var root = doc.RootElement;
msgs.Add(new
{
role = "tool",
tool_call_id = root.GetProperty("tool_use_id").GetString(),
content = root.GetProperty("content").GetString(),
});
continue;
}
catch { }
}
// _tool_use_blocks → OpenAI tool_calls 형식
if (m.Role == "assistant" && m.Content.StartsWith("{\"_tool_use_blocks\""))
{
try
{
using var doc = JsonDocument.Parse(m.Content);
var blocksArr = doc.RootElement.GetProperty("_tool_use_blocks");
var textContent = "";
var toolCallsList = new List<object>();
foreach (var b in blocksArr.EnumerateArray())
{
var bType = b.GetProperty("type").GetString();
if (bType == "text")
textContent = b.GetProperty("text").GetString() ?? "";
else if (bType == "tool_use")
{
var argsJson = b.TryGetProperty("input", out var inp) ? inp.GetRawText() : "{}";
toolCallsList.Add(new
{
id = b.GetProperty("id").GetString() ?? "",
type = "function",
function = new
{
name = b.GetProperty("name").GetString() ?? "",
arguments = argsJson,
}
});
}
}
msgs.Add(new
{
role = "assistant",
content = string.IsNullOrEmpty(textContent) ? (string?)null : textContent,
tool_calls = toolCallsList,
});
continue;
}
catch { }
}
msgs.Add(new { role = m.Role == "assistant" ? "assistant" : "user", content = m.Content });
}
if (strictToolOnlyDirective)
{
msgs.Add(new
{
role = "user",
content = "[TOOL_ONLY] 설명하지 말고 지금 즉시 tools 중 하나 이상을 호출하세요. 평문 응답 금지. 도구 호출만 하세요."
});
}
// OpenAI 호환 도구 정의 (형식 동일, watsonx에서 tools 필드 지원)
var toolDefs = tools.Select(t =>
{
var paramDict = new Dictionary<string, object>
{
["type"] = "object",
["properties"] = t.Parameters.Properties.ToDictionary(
kv => kv.Key,
kv => BuildPropertySchema(kv.Value, false)),
};
if (t.Parameters.Required is { Count: > 0 })
paramDict["required"] = t.Parameters.Required;
return new
{
type = "function",
function = new
{
name = t.Name,
description = t.Description,
parameters = paramDict,
}
};
}).ToArray();
// IBM watsonx: parameters 래퍼 사용, model 필드 없음
// tool_choice: "required" 지원 여부는 배포 버전마다 다를 수 있으므로
// forceToolCall=true일 때 추가하되, 오류 시 상위에서 ToolCallNotSupportedException으로 폴백됨
if (forceToolCall && useToolChoice)
{
return new
{
messages = msgs,
tools = toolDefs,
tool_choice = "required",
parameters = new
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
};
}
return new
{
messages = msgs,
tools = toolDefs,
parameters = new
{
temperature = ResolveToolTemperature(),
max_new_tokens = ResolveOpenAiCompatibleMaxTokens()
}
};
}
private sealed class ToolCallAccumulator
{
public int Index { get; init; }
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public StringBuilder Arguments { get; } = new();
public bool Emitted { get; set; }
}
private async Task<List<ContentBlock>> ReadOpenAiToolBlocksFromStreamAsync(
HttpResponseMessage resp,
bool usesIbmDeploymentApi,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
CancellationToken ct)
{
var blocks = new List<ContentBlock>();
var textBuilder = new StringBuilder();
await foreach (var evt in StreamOpenAiToolEventsAsync(resp, usesIbmDeploymentApi, prefetchToolCallAsync, ct).WithCancellation(ct))
{
if (evt.Kind == ToolStreamEventKind.TextDelta && !string.IsNullOrWhiteSpace(evt.Text))
{
textBuilder.Append(evt.Text);
}
else if (evt.Kind == ToolStreamEventKind.ToolCallReady && evt.ToolCall != null)
{
blocks.Add(evt.ToolCall);
}
}
var text = textBuilder.ToString().Trim();
var result = new List<ContentBlock>();
if (!string.IsNullOrWhiteSpace(text))
result.Add(new ContentBlock { Type = "text", Text = text });
result.AddRange(blocks);
if (!result.Any(b => b.Type == "tool_use"))
{
var textBlock = result.FirstOrDefault(b => b.Type == "text" && !string.IsNullOrWhiteSpace(b.Text));
if (textBlock != null)
{
var extracted = TryExtractToolCallsFromText(textBlock.Text);
if (extracted.Count > 0)
{
foreach (var block in extracted)
{
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
}
result.AddRange(extracted);
LogService.Debug($"[ToolUse] 텍스트에서 도구 호출 {extracted.Count}건 추출 (SSE 폴백 파싱)");
}
}
}
return result;
}
private async IAsyncEnumerable<ToolStreamEvent> StreamOpenAiToolEventsAsync(
List<ChatMessage> messages,
IReadOnlyCollection<IAgentTool> tools,
[EnumeratorCancellation] CancellationToken ct,
bool forceToolCall = false,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync = null)
{
var activeService = ResolveService();
var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo();
var endpoint = string.IsNullOrEmpty(resolvedEp)
? ResolveEndpointForService(activeService)
: resolvedEp;
var registered = GetActiveRegisteredModel();
var isIbmDeployment = UsesIbmDeploymentChatApi(activeService, registered, endpoint);
var body = isIbmDeployment
? BuildIbmToolBody(messages, tools, forceToolCall)
: BuildOpenAiToolBody(messages, tools, forceToolCall);
string url;
if (isIbmDeployment)
url = BuildIbmDeploymentChatUrl(endpoint, stream: true);
else if (activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase))
url = endpoint.TrimEnd('/') + "/api/chat";
else
url = endpoint.TrimEnd('/') + "/v1/chat/completions";
var json = JsonSerializer.Serialize(body);
using var req = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
await ApplyAuthHeaderAsync(req, ct);
using var resp = await SendWithTlsAsync(req, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (!resp.IsSuccessStatusCode)
{
var errBody = await resp.Content.ReadAsStringAsync(ct);
var detail = ExtractErrorDetail(errBody);
if (isIbmDeployment && forceToolCall && (int)resp.StatusCode == 400)
{
var fallbackBody = BuildIbmToolBody(messages, tools, forceToolCall: true, useToolChoice: false);
var fallbackJson = JsonSerializer.Serialize(fallbackBody);
using var retryReq = new HttpRequestMessage(HttpMethod.Post, url)
{
Content = new StringContent(fallbackJson, Encoding.UTF8, "application/json")
};
await ApplyAuthHeaderAsync(retryReq, ct);
using var retryResp = await SendWithTlsAsync(retryReq, allowInsecureTls, ct, HttpCompletionOption.ResponseHeadersRead);
if (!retryResp.IsSuccessStatusCode)
throw new ToolCallNotSupportedException($"{activeService} API 오류 ({retryResp.StatusCode}): {detail}");
await foreach (var evt in StreamOpenAiToolEventsAsync(retryResp, true, prefetchToolCallAsync, ct).WithCancellation(ct))
yield return evt;
yield break;
}
if ((int)resp.StatusCode == 400)
throw new ToolCallNotSupportedException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}");
}
await foreach (var evt in StreamOpenAiToolEventsAsync(resp, isIbmDeployment, prefetchToolCallAsync, ct).WithCancellation(ct))
yield return evt;
}
private async IAsyncEnumerable<ToolStreamEvent> StreamOpenAiToolEventsAsync(
HttpResponseMessage resp,
bool usesIbmDeploymentApi,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
[EnumeratorCancellation] CancellationToken ct)
{
using var stream = await resp.Content.ReadAsStreamAsync(ct);
using var reader = new StreamReader(stream);
var firstChunkReceived = false;
var toolAccumulators = new Dictionary<int, ToolCallAccumulator>();
var lastIbmGeneratedText = "";
while (!reader.EndOfStream && !ct.IsCancellationRequested)
{
var timeout = firstChunkReceived ? SubsequentChunkTimeout : FirstChunkTimeout;
var line = await ReadLineWithTimeoutAsync(reader, ct, timeout);
if (line == null)
{
if (!firstChunkReceived)
throw new TimeoutException("도구 호출 응답 첫 청크를 받지 못했습니다.");
break;
}
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("data: ", StringComparison.Ordinal))
continue;
firstChunkReceived = true;
var data = line["data: ".Length..].Trim();
if (string.Equals(data, "[DONE]", StringComparison.OrdinalIgnoreCase))
break;
using var doc = JsonDocument.Parse(data);
var root = doc.RootElement;
TryParseOpenAiUsage(root);
if (usesIbmDeploymentApi &&
root.TryGetProperty("status", out var statusEl) &&
string.Equals(statusEl.GetString(), "error", StringComparison.OrdinalIgnoreCase))
{
var detail = root.TryGetProperty("message", out var msgEl)
? msgEl.GetString()
: "IBM vLLM 도구 호출 응답 오류";
throw new ToolCallNotSupportedException(detail ?? "IBM vLLM 도구 호출 응답 오류");
}
if (TryExtractMessageToolBlocks(root, out var messageText, out var directToolBlocks))
{
if (!string.IsNullOrWhiteSpace(messageText))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, messageText);
if (directToolBlocks.Count > 0)
{
foreach (var toolBlock in directToolBlocks)
{
if (prefetchToolCallAsync != null)
toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock);
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock);
}
}
continue;
}
if (usesIbmDeploymentApi &&
root.TryGetProperty("results", out var resultsEl) &&
resultsEl.ValueKind == JsonValueKind.Array &&
resultsEl.GetArrayLength() > 0)
{
var first = resultsEl[0];
var generatedText = first.TryGetProperty("generated_text", out var generatedTextEl)
? generatedTextEl.GetString()
: first.TryGetProperty("output_text", out var outputTextEl)
? outputTextEl.GetString()
: null;
if (!string.IsNullOrEmpty(generatedText))
{
if (generatedText.StartsWith(lastIbmGeneratedText, StringComparison.Ordinal))
{
var delta = generatedText[lastIbmGeneratedText.Length..];
if (!string.IsNullOrEmpty(delta))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, delta);
lastIbmGeneratedText = generatedText;
}
else
{
if (!string.IsNullOrEmpty(generatedText))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, generatedText);
lastIbmGeneratedText = generatedText;
}
}
}
if (root.TryGetProperty("choices", out var choicesEl) &&
choicesEl.ValueKind == JsonValueKind.Array &&
choicesEl.GetArrayLength() > 0)
{
var firstChoice = choicesEl[0];
if (firstChoice.TryGetProperty("delta", out var deltaEl))
{
if (deltaEl.TryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var chunk = contentEl.GetString();
if (!string.IsNullOrEmpty(chunk))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, chunk);
}
if (deltaEl.TryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
{
foreach (var toolCallEl in toolCallsEl.EnumerateArray())
{
var index = toolCallEl.TryGetProperty("index", out var indexEl) &&
indexEl.TryGetInt32(out var parsedIndex)
? parsedIndex
: toolAccumulators.Count;
if (!toolAccumulators.TryGetValue(index, out var acc))
{
acc = new ToolCallAccumulator { Index = index };
toolAccumulators[index] = acc;
}
if (toolCallEl.TryGetProperty("id", out var idEl) && idEl.ValueKind == JsonValueKind.String)
acc.Id = idEl.GetString() ?? acc.Id;
if (toolCallEl.TryGetProperty("function", out var functionEl))
{
if (functionEl.TryGetProperty("name", out var nameEl) && nameEl.ValueKind == JsonValueKind.String)
acc.Name = nameEl.GetString() ?? acc.Name;
if (functionEl.TryGetProperty("arguments", out var argumentsEl))
{
if (argumentsEl.ValueKind == JsonValueKind.String)
acc.Arguments.Append(argumentsEl.GetString());
else if (argumentsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
acc.Arguments.Append(argumentsEl.GetRawText());
}
}
var emittedBlock = await TryCreateCompletedToolCallAsync(acc, prefetchToolCallAsync).ConfigureAwait(false);
if (emittedBlock != null)
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: emittedBlock);
}
}
}
if (firstChoice.TryGetProperty("message", out var messageEl))
{
if (TryExtractMessageToolBlocks(messageEl, out var messageText2, out var directToolBlocks2))
{
if (!string.IsNullOrWhiteSpace(messageText2))
yield return new ToolStreamEvent(ToolStreamEventKind.TextDelta, messageText2);
if (directToolBlocks2.Count > 0)
{
foreach (var toolBlock in directToolBlocks2)
{
if (prefetchToolCallAsync != null)
toolBlock.PrefetchedExecutionTask = prefetchToolCallAsync(toolBlock);
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: toolBlock);
}
}
}
}
}
}
foreach (var acc in toolAccumulators.Values.OrderBy(a => a.Index))
{
var emittedBlock = await TryCreateCompletedToolCallAsync(acc, prefetchToolCallAsync, forceEmit: true).ConfigureAwait(false);
if (emittedBlock != null)
yield return new ToolStreamEvent(ToolStreamEventKind.ToolCallReady, ToolCall: emittedBlock);
}
yield return new ToolStreamEvent(ToolStreamEventKind.Completed);
}
private static bool TryExtractMessageToolBlocks(
JsonElement messageOrRoot,
out string text,
out List<ContentBlock> toolBlocks)
{
text = "";
toolBlocks = new List<ContentBlock>();
JsonElement message = messageOrRoot;
if (messageOrRoot.TryGetProperty("message", out var nestedMessage))
message = nestedMessage;
var consumed = false;
if (message.TryGetProperty("content", out var contentEl) &&
contentEl.ValueKind == JsonValueKind.String)
{
var parsedText = contentEl.GetString();
if (!string.IsNullOrWhiteSpace(parsedText))
{
text = parsedText;
consumed = true;
}
}
if (message.TryGetProperty("tool_calls", out var toolCallsEl) &&
toolCallsEl.ValueKind == JsonValueKind.Array)
{
foreach (var tc in toolCallsEl.EnumerateArray())
{
if (!tc.TryGetProperty("function", out var functionEl))
continue;
JsonElement? parsedArgs = null;
if (functionEl.TryGetProperty("arguments", out var argsEl))
{
if (argsEl.ValueKind == JsonValueKind.String)
{
try
{
using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}");
parsedArgs = argsDoc.RootElement.Clone();
}
catch { parsedArgs = null; }
}
else if (argsEl.ValueKind is JsonValueKind.Object or JsonValueKind.Array)
{
parsedArgs = argsEl.Clone();
}
}
toolBlocks.Add(new ContentBlock
{
Type = "tool_use",
ToolName = functionEl.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "" : "",
ToolId = tc.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12],
ToolInput = parsedArgs,
});
}
consumed = true;
}
return consumed;
}
private static bool LooksLikeCompleteJson(string json)
{
if (string.IsNullOrWhiteSpace(json))
return false;
json = json.Trim();
if (!(json.StartsWith('{') || json.StartsWith('[')))
return false;
try
{
using var _ = JsonDocument.Parse(json);
return true;
}
catch
{
return false;
}
}
private static async Task<ContentBlock?> TryCreateCompletedToolCallAsync(
ToolCallAccumulator acc,
Func<ContentBlock, Task<ToolPrefetchResult?>>? prefetchToolCallAsync,
bool forceEmit = false)
{
if (acc.Emitted || string.IsNullOrWhiteSpace(acc.Name))
return null;
var argsJson = acc.Arguments.ToString().Trim();
JsonElement? parsedArgs = null;
if (!string.IsNullOrEmpty(argsJson))
{
if (!forceEmit && !LooksLikeCompleteJson(argsJson))
return null;
try
{
using var argsDoc = JsonDocument.Parse(argsJson);
parsedArgs = argsDoc.RootElement.Clone();
}
catch
{
if (!forceEmit)
return null;
}
}
var block = new ContentBlock
{
Type = "tool_use",
ToolName = acc.Name,
ToolId = string.IsNullOrWhiteSpace(acc.Id) ? Guid.NewGuid().ToString("N")[..12] : acc.Id,
ToolInput = parsedArgs,
};
if (prefetchToolCallAsync != null)
block.PrefetchedExecutionTask = prefetchToolCallAsync(block);
acc.Emitted = true;
return block;
}
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
/// <summary>ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함.</summary>
private static object BuildPropertySchema(Agent.ToolProperty prop, bool upperCaseType)
{
var typeName = upperCaseType ? prop.Type.ToUpperInvariant() : prop.Type;
if (prop.Type.Equals("array", StringComparison.OrdinalIgnoreCase) && prop.Items != null)
{
return new
{
type = typeName,
description = prop.Description,
items = BuildPropertySchema(prop.Items, upperCaseType)
};
}
// enum 값이 있으면 포함
if (prop.Enum is { Count: > 0 })
return new { type = typeName, description = prop.Description, @enum = prop.Enum };
return new { type = typeName, description = prop.Description };
}
/// <summary>에러 응답 본문에서 핵심 메시지를 추출합니다.</summary>
private static string ExtractErrorDetail(string errBody)
{
if (string.IsNullOrWhiteSpace(errBody)) return "응답 없음";
try
{
using var doc = JsonDocument.Parse(errBody);
// Ollama: {"error":"..."}
if (doc.RootElement.TryGetProperty("error", out var err))
{
if (err.ValueKind == JsonValueKind.String)
return err.GetString() ?? errBody;
if (err.ValueKind == JsonValueKind.Object && err.TryGetProperty("message", out var m))
return m.GetString() ?? errBody;
}
}
catch { }
// JSON 아니면 원본 (최대 500자)
return errBody.Length > 500 ? errBody[..500] + "…" : errBody;
}
}
/// <summary>도구 호출 자체가 서버에서 거부된 경우 (400). 일반 텍스트 응답으로 폴백 시도 가능.</summary>
public class ToolCallNotSupportedException : Exception
{
public ToolCallNotSupportedException(string message) : base(message) { }
}