using System.Net.Http; using System.Runtime.CompilerServices; 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 파라미터 } /// 도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다. public async Task> SendWithToolsAsync( List messages, IReadOnlyCollection tools, CancellationToken ct = default) { 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), _ => 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) { var activeService = ResolveService(); var body = BuildOpenAiToolBody(messages, tools); // 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo) var (resolvedEp, _, allowInsecureTls) = ResolveServerInfo(); var endpoint = string.IsNullOrEmpty(resolvedEp) ? ResolveEndpointForService(activeService) : resolvedEp; var url = activeService.ToLowerInvariant() == "ollama" ? endpoint.TrimEnd('/') + "/api/chat" : 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); if (!resp.IsSuccessStatusCode) { var errBody = await resp.Content.ReadAsStringAsync(ct); var detail = ExtractErrorDetail(errBody); LogService.Warn($"[ToolUse] {activeService} API 오류 ({resp.StatusCode}): {errBody}"); // 400 BadRequest → 도구 없이 일반 응답으로 폴백 시도 if ((int)resp.StatusCode == 400) throw new ToolCallNotSupportedException( $"{activeService} API 오류 ({resp.StatusCode}): {detail}"); throw new HttpRequestException($"{activeService} API 오류 ({resp.StatusCode}): {detail}"); } var respJson = await resp.Content.ReadAsStringAsync(ct); using var doc = JsonDocument.Parse(respJson); var root = doc.RootElement; TryParseOpenAiUsage(root); var blocks = new List(); // Ollama 형식: root.message // OpenAI 형식: root.choices[0].message JsonElement message; if (root.TryGetProperty("message", out var ollamaMsg)) message = ollamaMsg; else if (root.TryGetProperty("choices", out var choices) && choices.GetArrayLength() > 0) message = choices[0].TryGetProperty("message", out var choiceMsg) ? choiceMsg : default; else return blocks; // 텍스트 응답 if (message.TryGetProperty("content", out var content)) { var text = content.GetString(); if (!string.IsNullOrWhiteSpace(text)) blocks.Add(new ContentBlock { Type = "text", Text = text }); } // 도구 호출 (tool_calls 배열) if (message.TryGetProperty("tool_calls", out var toolCalls)) { foreach (var tc in toolCalls.EnumerateArray()) { if (!tc.TryGetProperty("function", out var func)) continue; // arguments: 표준(OpenAI)은 JSON 문자열, Ollama/qwen 등은 JSON 객체를 직접 반환하기도 함 JsonElement? parsedArgs = null; if (func.TryGetProperty("arguments", out var argsEl)) { if (argsEl.ValueKind == JsonValueKind.String) { // 표준: 문자열로 감싸진 JSON → 파싱 try { using var argsDoc = JsonDocument.Parse(argsEl.GetString() ?? "{}"); parsedArgs = argsDoc.RootElement.Clone(); } catch { parsedArgs = null; } } else if (argsEl.ValueKind == JsonValueKind.Object || argsEl.ValueKind == JsonValueKind.Array) { // Ollama/qwen 방식: 이미 JSON 객체 — 그대로 사용 parsedArgs = argsEl.Clone(); } } blocks.Add(new ContentBlock { Type = "tool_use", ToolName = func.TryGetProperty("name", out var fnm) ? fnm.GetString() ?? "" : "", ToolId = tc.TryGetProperty("id", out var id) ? id.GetString() ?? Guid.NewGuid().ToString("N")[..12] : Guid.NewGuid().ToString("N")[..12], ToolInput = parsedArgs, }); } } return blocks; } private object BuildOpenAiToolBody(List messages, IReadOnlyCollection tools) { 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 isOllama = activeService.Equals("ollama", StringComparison.OrdinalIgnoreCase); if (isOllama) { return new { model = activeModel, messages = msgs, tools = toolDefs, stream = false, options = new { temperature = ResolveTemperature() } }; } var body = new Dictionary { ["model"] = activeModel, ["messages"] = msgs, ["tools"] = toolDefs, ["stream"] = false, ["temperature"] = ResolveTemperature(), ["max_tokens"] = ResolveOpenAiCompatibleMaxTokens(), }; var effort = ResolveReasoningEffort(); if (!string.IsNullOrWhiteSpace(effort)) body["reasoning_effort"] = effort; return body; } // ─── 공통 헬퍼 ───────────────────────────────────────────────────── /// 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) { } }