Initial commit to new repository
This commit is contained in:
723
src/AxCopilot/Services/LlmService.ToolUse.cs
Normal file
723
src/AxCopilot/Services/LlmService.ToolUse.cs
Normal file
@@ -0,0 +1,723 @@
|
||||
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;
|
||||
|
||||
/// <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 파라미터
|
||||
}
|
||||
|
||||
/// <summary>도구 정의를 포함하여 LLM에 요청하고, 텍스트 + tool_use 블록을 파싱하여 반환합니다.</summary>
|
||||
public async Task<List<ContentBlock>> SendWithToolsAsync(
|
||||
List<ChatMessage> messages,
|
||||
IReadOnlyCollection<IAgentTool> 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을 지원하지 않습니다.")
|
||||
};
|
||||
}
|
||||
|
||||
/// <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 = Math.Max(llm.MaxContextTokens, 4096),
|
||||
temperature = llm.Temperature,
|
||||
system = systemPrompt,
|
||||
messages = msgs,
|
||||
tools = toolDefs,
|
||||
stream = false
|
||||
};
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
model = activeModel,
|
||||
max_tokens = Math.Max(llm.MaxContextTokens, 4096),
|
||||
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)
|
||||
{
|
||||
var llm = _settings.Settings.Llm;
|
||||
var activeService = ResolveService();
|
||||
var body = BuildOpenAiToolBody(messages, tools);
|
||||
|
||||
// 등록 모델의 커스텀 엔드포인트 우선 사용 (ResolveServerInfo)
|
||||
var (resolvedEp, _) = 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 _http.SendAsync(req, 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<ContentBlock>();
|
||||
|
||||
// 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<ChatMessage> messages, IReadOnlyCollection<IAgentTool> tools)
|
||||
{
|
||||
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 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<string, object?>
|
||||
{
|
||||
["model"] = activeModel,
|
||||
["messages"] = msgs,
|
||||
["tools"] = toolDefs,
|
||||
["stream"] = false,
|
||||
["temperature"] = ResolveTemperature(),
|
||||
["max_tokens"] = llm.MaxContextTokens,
|
||||
};
|
||||
var effort = ResolveReasoningEffort();
|
||||
if (!string.IsNullOrWhiteSpace(effort))
|
||||
body["reasoning_effort"] = effort;
|
||||
return body;
|
||||
}
|
||||
|
||||
// ─── 공통 헬퍼 ─────────────────────────────────────────────────────
|
||||
|
||||
/// <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) { }
|
||||
}
|
||||
Reference in New Issue
Block a user