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; /// /// LlmService의 Function Calling (tool_use) 확장. /// Claude tool_use, Gemini function_calling 프로토콜을 지원합니다. /// 기존 SendAsync/StreamAsync는 변경하지 않고, 에이전트 전용 메서드를 추가합니다. /// public partial class LlmService { /// LLM 응답에서 파싱된 컨텐츠 블록. 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? 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); /// 도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다. /// /// true이면 tool_choice: "required"를 요청에 추가하여 모델이 반드시 도구를 호출하도록 강제합니다. /// 아직 한 번도 도구를 호출하지 않은 첫 번째 요청에 사용하세요. /// Claude/Gemini는 지원하지 않으므로 vLLM/Ollama/OpenAI 계열에만 적용됩니다. /// public async Task> SendWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct = default, bool forceToolCall = false, Func>? 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 StreamWithToolsAsync( List messages, IReadOnlyCollection tools, bool forceToolCall = false, Func>? 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 스트리밍을 지원하지 않습니다."); } } /// 도구 실행 결과를 LLM에 피드백하기 위한 메시지를 생성합니다. 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> SendSigmoidWithToolsAsync( List messages, IReadOnlyCollection 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(); 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 messages, IReadOnlyCollection tools) { var llm = _settings.Settings.Llm; var msgs = new List(); 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(); 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(); 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> SendGeminiWithToolsAsync( List messages, IReadOnlyCollection 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(); 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 messages, IReadOnlyCollection tools) { var contents = new List(); 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(); 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 { 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 { ["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> SendOpenAiWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct, bool forceToolCall = false, Func>? 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); } /// /// Qwen/vLLM 등이 tool_calls 대신 텍스트로 도구 호출을 출력하는 경우를 파싱합니다. /// 지원 패턴: /// 1. <tool_call>{"name":"...", "arguments":{...}}</tool_call> /// 2. Qwen3 <tool_call>\n{"name":"...", "arguments":{...}}\n</tool_call> /// 3. 여러 건의 연속 tool_call 태그 /// private static List TryExtractToolCallsFromText(string text) { var results = new List(); if (string.IsNullOrWhiteSpace(text)) return results; // 패턴 1: ... 태그 (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; } /// {"name":"...", "arguments":{...}} 형식 JSON을 ContentBlock으로 변환. 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; } } /// 이름과 arguments JSON이 별도로 주어진 경우. 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 messages, IReadOnlyCollection tools, bool forceToolCall = false) { var llm = _settings.Settings.Llm; var msgs = new List(); 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(); 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(); 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 { ["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 { ["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; } /// IBM 배포형 /text/chat 전용 도구 바디. parameters 래퍼 사용, model 필드 없음. private object BuildIbmToolBody( List messages, IReadOnlyCollection 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(); // 시스템 프롬프트 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(); 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 { ["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> ReadOpenAiToolBlocksFromStreamAsync( HttpResponseMessage resp, bool usesIbmDeploymentApi, Func>? prefetchToolCallAsync, CancellationToken ct) { var blocks = new List(); 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(); 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 StreamOpenAiToolEventsAsync( List messages, IReadOnlyCollection tools, [EnumeratorCancellation] CancellationToken ct, bool forceToolCall = false, Func>? 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 StreamOpenAiToolEventsAsync( HttpResponseMessage resp, bool usesIbmDeploymentApi, Func>? 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(); 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 toolBlocks) { text = ""; toolBlocks = new List(); 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 TryCreateCompletedToolCallAsync( ToolCallAccumulator acc, Func>? 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; } // ─── 공통 헬퍼 ───────────────────────────────────────────────────── /// ToolProperty를 LLM API용 스키마 객체로 변환. array/enum/items 포함. 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 }; } /// 에러 응답 본문에서 핵심 메시지를 추출합니다. 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; } } /// 도구 호출 자체가 서버에서 거부된 경우 (400). 일반 텍스트 응답으로 폴백 시도 가능. public class ToolCallNotSupportedException : Exception { public ToolCallNotSupportedException(string message) : base(message) { } }