From 167b7c2ec1d0b3ab4e5e006d214b39a0fb360fba Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Sun, 15 Feb 2026 19:13:51 -0500 Subject: [PATCH] Feature: Add tool-use support to AI vision clients Add IAIToolAwareVisionClient interface and OpenAIToolUseHelper for function-calling via /v1/chat/completions. OpenAI and LlamaCpp clients now support multi-round tool calls, letting the AI query the database during receipt image analysis. Co-Authored-By: Claude Opus 4.6 --- MoneyMap/Services/AIVisionClient.cs | 594 +++++++++++++++++++++++++--- 1 file changed, 538 insertions(+), 56 deletions(-) diff --git a/MoneyMap/Services/AIVisionClient.cs b/MoneyMap/Services/AIVisionClient.cs index 44d279a..d289cfd 100644 --- a/MoneyMap/Services/AIVisionClient.cs +++ b/MoneyMap/Services/AIVisionClient.cs @@ -1,3 +1,4 @@ +using MoneyMap.Services.AITools; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; @@ -29,9 +30,226 @@ namespace MoneyMap.Services } /// - /// OpenAI Vision API client. + /// Extended interface for vision clients that support tool use / function calling. /// - public class OpenAIVisionClient : IAIVisionClient + public interface IAIToolAwareVisionClient : IAIVisionClient + { + bool SupportsToolUse { get; } + + Task AnalyzeImageWithToolsAsync( + string base64Image, + string mediaType, + string prompt, + string model, + List tools, + Func> toolExecutor, + int maxToolRounds = 5); + } + + /// + /// Shared helper for the OpenAI-compatible tool-use wire format. + /// Used by both OpenAIVisionClient and LlamaCppVisionClient since they share /v1/chat/completions. + /// + public static class OpenAIToolUseHelper + { + /// + /// Convert AIToolDefinitions to the OpenAI tools array format. + /// + public static List BuildToolsArray(List tools) + { + return tools.Select(t => (object)new + { + type = "function", + function = new + { + name = t.Name, + description = t.Description, + parameters = new + { + type = "object", + properties = t.Parameters.ToDictionary( + p => p.Name, + p => (object)new { type = p.Type, description = p.Description } + ), + required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray() + } + } + }).ToList(); + } + + /// + /// Execute the tool-use loop for OpenAI-compatible /v1/chat/completions endpoints. + /// + public static async Task ExecuteWithToolsAsync( + HttpClient httpClient, + string apiUrl, + Action configureHeaders, + string model, + List initialMessages, + List toolsArray, + Func> toolExecutor, + int maxToolRounds, + int maxTokens, + ILogger logger) + { + // Build mutable message list + var messages = new List(initialMessages); + + for (int round = 0; round <= maxToolRounds; round++) + { + var requestBody = new Dictionary + { + ["model"] = model, + ["messages"] = messages, + ["max_tokens"] = maxTokens, + ["temperature"] = 0.1 + }; + + // Only include tools if we haven't exhausted rounds + if (round < maxToolRounds && toolsArray.Count > 0) + { + requestBody["tools"] = toolsArray; + requestBody["tool_choice"] = "auto"; + } + + configureHeaders(httpClient); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await httpClient.PostAsync(apiUrl, content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + logger.LogError("API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var choice = responseObj.GetProperty("choices")[0]; + var message = choice.GetProperty("message"); + var finishReason = choice.GetProperty("finish_reason").GetString(); + + // Check for tool calls + var hasToolCalls = message.TryGetProperty("tool_calls", out var toolCallsElement) && + toolCallsElement.ValueKind == JsonValueKind.Array && + toolCallsElement.GetArrayLength() > 0; + + if (hasToolCalls || finishReason == "tool_calls") + { + if (!hasToolCalls) + { + // finish_reason says tool_calls but no tool_calls array - treat as final response + var fallbackContent = message.TryGetProperty("content", out var fc) ? fc.GetString() : null; + return VisionApiResult.Success(CleanJsonResponse(fallbackContent)); + } + + logger.LogInformation("Tool-use round {Round}: model requested {Count} tool calls", + round + 1, toolCallsElement.GetArrayLength()); + + // Add the assistant message (with tool_calls) to conversation + messages.Add(JsonSerializer.Deserialize(message.GetRawText())!); + + // Execute each tool call and add results + foreach (var tc in toolCallsElement.EnumerateArray()) + { + var toolCall = new AIToolCall + { + Id = tc.GetProperty("id").GetString() ?? "", + Name = tc.GetProperty("function").GetProperty("name").GetString() ?? "", + Arguments = ParseArguments(tc.GetProperty("function").GetProperty("arguments").GetString()) + }; + + logger.LogInformation("Executing tool: {ToolName}", toolCall.Name); + var result = await toolExecutor(toolCall); + + messages.Add(new + { + role = "tool", + tool_call_id = toolCall.Id, + content = result.Content + }); + } + + continue; // Send another request with tool results + } + + // No tool calls - extract final content + var messageContent = message.TryGetProperty("content", out var contentElement) + ? contentElement.GetString() + : null; + + return VisionApiResult.Success(CleanJsonResponse(messageContent)); + } + + return VisionApiResult.Failure("Exceeded maximum tool-use rounds without getting a final response."); + } + + private static Dictionary ParseArguments(string? argsJson) + { + if (string.IsNullOrWhiteSpace(argsJson)) + return new(); + + try + { + var element = JsonSerializer.Deserialize(argsJson); + var dict = new Dictionary(); + foreach (var prop in element.EnumerateObject()) + { + dict[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => prop.Value.GetRawText() + }; + } + return dict; + } + catch + { + return new(); + } + } + + public static string CleanJsonResponse(string? content) + { + var trimmed = content?.Trim() ?? ""; + + // Strip markdown code fences + if (trimmed.StartsWith("```json")) + { + trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); + } + else if (trimmed.StartsWith("```")) + { + trimmed = trimmed.Replace("```", "").Trim(); + } + + // If the response doesn't start with '{', try to extract the JSON object. + // This handles HTML error pages, XML-wrapped responses, or other non-JSON wrapping. + if (!trimmed.StartsWith("{")) + { + var firstBrace = trimmed.IndexOf('{'); + var lastBrace = trimmed.LastIndexOf('}'); + if (firstBrace >= 0 && lastBrace > firstBrace) + { + trimmed = trimmed[firstBrace..(lastBrace + 1)]; + } + } + + return trimmed; + } + } + + /// + /// OpenAI Vision API client with tool-use support. + /// + public class OpenAIVisionClient : IAIToolAwareVisionClient { private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; @@ -44,12 +262,12 @@ namespace MoneyMap.Services _logger = logger; } + public bool SupportsToolUse => true; + public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) { - var apiKey = Environment.GetEnvironmentVariable("OPENAI_API_KEY") - ?? _configuration["OpenAI:ApiKey"]; - - if (string.IsNullOrWhiteSpace(apiKey)) + var apiKey = GetApiKey(); + if (apiKey == null) return VisionApiResult.Failure("OpenAI API key not configured. Set OPENAI_API_KEY environment variable or OpenAI:ApiKey in appsettings.json"); var requestBody = new @@ -101,7 +319,7 @@ namespace MoneyMap.Services .GetProperty("content") .GetString(); - return VisionApiResult.Success(CleanJsonResponse(messageContent)); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); } catch (Exception ex) { @@ -110,21 +328,66 @@ namespace MoneyMap.Services } } - private static string CleanJsonResponse(string? content) + public async Task AnalyzeImageWithToolsAsync( + string base64Image, string mediaType, string prompt, string model, + List tools, Func> toolExecutor, + int maxToolRounds = 5) { - var trimmed = content?.Trim() ?? ""; - if (trimmed.StartsWith("```json")) + var apiKey = GetApiKey(); + if (apiKey == null) + return VisionApiResult.Failure("OpenAI API key not configured."); + + var initialMessages = new List { - trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); + new + { + role = "user", + content = new object[] + { + new { type = "text", text = prompt }, + new + { + type = "image_url", + image_url = new { url = $"data:{mediaType};base64,{base64Image}" } + } + } + } + }; + + try + { + return await OpenAIToolUseHelper.ExecuteWithToolsAsync( + _httpClient, + "https://api.openai.com/v1/chat/completions", + client => + { + client.DefaultRequestHeaders.Clear(); + client.DefaultRequestHeaders.Add("Authorization", $"Bearer {apiKey}"); + }, + model, + initialMessages, + OpenAIToolUseHelper.BuildToolsArray(tools), + toolExecutor, + maxToolRounds, + maxTokens: 4096, + _logger); + } + catch (Exception ex) + { + _logger.LogError(ex, "OpenAI tool-use call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"OpenAI API error: {ex.Message}"); } - return trimmed; } + + private string? GetApiKey() => + Environment.GetEnvironmentVariable("OPENAI_API_KEY") + ?? _configuration["OpenAI:ApiKey"]; } /// - /// Anthropic Claude Vision API client. + /// Anthropic Claude Vision API client with tool-use support. /// - public class ClaudeVisionClient : IAIVisionClient + public class ClaudeVisionClient : IAIToolAwareVisionClient { private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; @@ -137,12 +400,12 @@ namespace MoneyMap.Services _logger = logger; } + public bool SupportsToolUse => true; + public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) { - var apiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") - ?? _configuration["Anthropic:ApiKey"]; - - if (string.IsNullOrWhiteSpace(apiKey)) + var apiKey = GetApiKey(); + if (apiKey == null) return VisionApiResult.Failure("Anthropic API key not configured. Set ANTHROPIC_API_KEY environment variable or Anthropic:ApiKey in appsettings.json"); var requestBody = new @@ -174,10 +437,7 @@ namespace MoneyMap.Services try { - _httpClient.DefaultRequestHeaders.Clear(); - _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); - _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); - + ConfigureHeaders(); var json = JsonSerializer.Serialize(requestBody); var content = new StringContent(json, Encoding.UTF8, "application/json"); @@ -198,7 +458,7 @@ namespace MoneyMap.Services .GetProperty("text") .GetString(); - return VisionApiResult.Success(CleanJsonResponse(messageContent)); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); } catch (Exception ex) { @@ -207,34 +467,225 @@ namespace MoneyMap.Services } } - private static string CleanJsonResponse(string? content) + public async Task AnalyzeImageWithToolsAsync( + string base64Image, string mediaType, string prompt, string model, + List tools, Func> toolExecutor, + int maxToolRounds = 5) { - var trimmed = content?.Trim() ?? ""; - if (trimmed.StartsWith("```json")) + var apiKey = GetApiKey(); + if (apiKey == null) + return VisionApiResult.Failure("Anthropic API key not configured."); + + // Build Anthropic-format tools array + var anthropicTools = tools.Select(t => new { - trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); + name = t.Name, + description = t.Description, + input_schema = new + { + type = "object", + properties = t.Parameters.ToDictionary( + p => p.Name, + p => (object)new { type = p.Type, description = p.Description } + ), + required = t.Parameters.Where(p => p.Required).Select(p => p.Name).ToArray() + } + }).ToList(); + + // Initial message with image + var messages = new List + { + new + { + role = "user", + content = new object[] + { + new + { + type = "image", + source = new + { + type = "base64", + media_type = mediaType, + data = base64Image + } + }, + new { type = "text", text = prompt } + } + } + }; + + try + { + for (int round = 0; round <= maxToolRounds; round++) + { + var requestBody = new Dictionary + { + ["model"] = model, + ["max_tokens"] = 4096, + ["messages"] = messages + }; + + if (round < maxToolRounds && anthropicTools.Count > 0) + requestBody["tools"] = anthropicTools; + + ConfigureHeaders(); + var json = JsonSerializer.Serialize(requestBody); + var content = new StringContent(json, Encoding.UTF8, "application/json"); + + var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content); + + if (!response.IsSuccessStatusCode) + { + var errorContent = await response.Content.ReadAsStringAsync(); + _logger.LogError("Anthropic API error ({StatusCode}): {Error}", response.StatusCode, errorContent); + return VisionApiResult.Failure($"Anthropic API error ({response.StatusCode}): {errorContent}"); + } + + var responseJson = await response.Content.ReadAsStringAsync(); + var responseObj = JsonSerializer.Deserialize(responseJson); + + var stopReason = responseObj.GetProperty("stop_reason").GetString(); + var contentBlocks = responseObj.GetProperty("content"); + + // Check for tool_use blocks + var toolUseBlocks = contentBlocks.EnumerateArray() + .Where(b => b.GetProperty("type").GetString() == "tool_use") + .ToList(); + + if (stopReason == "tool_use" && toolUseBlocks.Count > 0) + { + _logger.LogInformation("Claude tool-use round {Round}: {Count} tool calls", + round + 1, toolUseBlocks.Count); + + // Add assistant response to messages (contains tool_use blocks) + var assistantContent = JsonSerializer.Deserialize(contentBlocks.GetRawText())!; + messages.Add(new { role = "assistant", content = assistantContent }); + + // Build tool_result blocks + var toolResults = new List(); + foreach (var block in toolUseBlocks) + { + var toolCall = new AIToolCall + { + Id = block.GetProperty("id").GetString() ?? "", + Name = block.GetProperty("name").GetString() ?? "", + Arguments = ParseAnthropicInput(block.GetProperty("input")) + }; + + _logger.LogInformation("Executing tool: {ToolName}", toolCall.Name); + var result = await toolExecutor(toolCall); + + toolResults.Add(new + { + type = "tool_result", + tool_use_id = toolCall.Id, + content = result.Content, + is_error = result.IsError + }); + } + + messages.Add(new { role = "user", content = toolResults }); + continue; + } + + // Extract final text content + var textBlock = contentBlocks.EnumerateArray() + .FirstOrDefault(b => b.GetProperty("type").GetString() == "text"); + + var text = textBlock.ValueKind != JsonValueKind.Undefined + ? textBlock.GetProperty("text").GetString() + : null; + + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(text)); + } + + return VisionApiResult.Failure("Exceeded maximum tool-use rounds."); + } + catch (Exception ex) + { + _logger.LogError(ex, "Claude tool-use call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"Anthropic API error: {ex.Message}"); } - return trimmed; } + + private static Dictionary ParseAnthropicInput(JsonElement input) + { + var dict = new Dictionary(); + if (input.ValueKind == JsonValueKind.Object) + { + foreach (var prop in input.EnumerateObject()) + { + dict[prop.Name] = prop.Value.ValueKind switch + { + JsonValueKind.String => prop.Value.GetString(), + JsonValueKind.Number => prop.Value.GetRawText(), + JsonValueKind.True => "true", + JsonValueKind.False => "false", + JsonValueKind.Null => null, + _ => prop.Value.GetRawText() + }; + } + } + return dict; + } + + private void ConfigureHeaders() + { + var apiKey = GetApiKey()!; + _httpClient.DefaultRequestHeaders.Clear(); + _httpClient.DefaultRequestHeaders.Add("x-api-key", apiKey); + _httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01"); + } + + private string? GetApiKey() => + Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY") + ?? _configuration["Anthropic:ApiKey"]; } /// - /// llama.cpp server client using OpenAI-compatible vision API for local LLM inference. + /// llama.cpp server client using OpenAI-compatible vision API with tool-use support. /// - public class LlamaCppVisionClient : IAIVisionClient + public class LlamaCppVisionClient : IAIToolAwareVisionClient { private readonly HttpClient _httpClient; private readonly IConfiguration _configuration; private readonly ILogger _logger; + // Model families whose Jinja chat templates support the OpenAI tool role format. + private static readonly string[] _toolCapableModelPrefixes = new[] + { + "qwen3", "qwen2.5", "hermes", "functionary", "mistral" + }; + + private string? _currentModel; + public LlamaCppVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) { _httpClient = httpClient; - _httpClient.Timeout = TimeSpan.FromMinutes(5); // Local models can be slow + _httpClient.Timeout = TimeSpan.FromMinutes(5); _configuration = configuration; _logger = logger; } + /// + /// Whether the current model supports OpenAI-style tool/function calling. + /// Only certain model families (Qwen3, Hermes, etc.) have chat templates that handle the tool role. + /// + public bool SupportsToolUse => + _currentModel != null && + _toolCapableModelPrefixes.Any(p => + _currentModel.StartsWith(p, StringComparison.OrdinalIgnoreCase)); + + /// + /// Set the model name so SupportsToolUse can be evaluated per-model. + /// Called by AIReceiptParser before the tool-use check. + /// + public void SetCurrentModel(string model) + { + _currentModel = model.StartsWith("llamacpp:") ? model[9..] : model; + } + /// /// Get available models from the llama.cpp server. /// @@ -256,7 +707,7 @@ namespace MoneyMap.Services var modelsResponse = JsonSerializer.Deserialize(json); return modelsResponse?.Data? - .Where(m => !m.Id.StartsWith("mmproj-")) // Filter out multimodal projectors + .Where(m => !m.Id.StartsWith("mmproj-")) .Select(m => new LlamaCppModel { Id = m.Id, @@ -279,7 +730,7 @@ namespace MoneyMap.Services public async Task SendTextPromptAsync(string prompt, string? model = null) { var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - var llamaModel = model ?? "GLM-4.6V-UD-Q4_K_XL-00001-of-00002"; + var llamaModel = model ?? _configuration["AI:ReceiptParsingModel"] ?? "Qwen3-8B-Q6_K"; if (llamaModel.StartsWith("llamacpp:")) llamaModel = llamaModel[9..]; @@ -324,7 +775,7 @@ namespace MoneyMap.Services .GetString(); _logger.LogInformation("LlamaCpp: Text prompt completed successfully"); - return VisionApiResult.Success(CleanJsonResponse(messageContent)); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { @@ -341,8 +792,6 @@ namespace MoneyMap.Services public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) { var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - - // Strip "llamacpp:" prefix if present var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model; _logger.LogInformation("LlamaCpp: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", @@ -397,7 +846,7 @@ namespace MoneyMap.Services .GetString(); _logger.LogInformation("LlamaCpp: Successfully parsed response"); - return VisionApiResult.Success(CleanJsonResponse(messageContent)); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { @@ -411,19 +860,64 @@ namespace MoneyMap.Services } } - private static string CleanJsonResponse(string? content) + public async Task AnalyzeImageWithToolsAsync( + string base64Image, string mediaType, string prompt, string model, + List tools, Func> toolExecutor, + int maxToolRounds = 5) { - var trimmed = content?.Trim() ?? ""; - if (trimmed.StartsWith("```json")) + var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; + var llamaModel = model.StartsWith("llamacpp:") ? model[9..] : model; + + _logger.LogInformation("LlamaCpp: Starting tool-use request with model {Model}", llamaModel); + + var initialMessages = new List { - trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); + new + { + role = "user", + content = new object[] + { + new + { + type = "image_url", + image_url = new { url = $"data:{mediaType};base64,{base64Image}" } + }, + new { type = "text", text = prompt } + } + } + }; + + try + { + return await OpenAIToolUseHelper.ExecuteWithToolsAsync( + _httpClient, + $"{baseUrl.TrimEnd('/')}/v1/chat/completions", + _ => { }, // No auth headers needed for local llama.cpp + llamaModel, + initialMessages, + OpenAIToolUseHelper.BuildToolsArray(tools), + toolExecutor, + maxToolRounds, + maxTokens: 4096, + _logger); + } + catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) + { + _logger.LogError("llama.cpp tool-use request timed out"); + return VisionApiResult.Failure("llama.cpp request timed out."); + } + catch (Exception ex) + { + _logger.LogError(ex, "llama.cpp tool-use call failed: {Message}", ex.Message); + return VisionApiResult.Failure($"llama.cpp API error: {ex.Message}"); } - return trimmed; } } /// /// Ollama Vision API client for local LLM inference. + /// Does NOT support tool use (uses /api/generate endpoint). + /// Falls back to enriched prompt with pre-fetched context. /// public class OllamaVisionClient : IAIVisionClient { @@ -434,7 +928,7 @@ namespace MoneyMap.Services public OllamaVisionClient(HttpClient httpClient, IConfiguration configuration, ILogger logger) { _httpClient = httpClient; - _httpClient.Timeout = TimeSpan.FromMinutes(5); // Local models can be slow + _httpClient.Timeout = TimeSpan.FromMinutes(5); _configuration = configuration; _logger = logger; } @@ -442,8 +936,6 @@ namespace MoneyMap.Services public async Task AnalyzeImageAsync(string base64Image, string mediaType, string prompt, string model) { var baseUrl = _configuration["AI:ModelsEndpoint"] ?? "http://athena.lan:11434"; - - // Strip "ollama:" prefix if present var ollamaModel = model.StartsWith("ollama:") ? model[7..] : model; _logger.LogInformation("Ollama: Sending request to {BaseUrl} with model {Model}, image size: {Size} bytes", @@ -483,7 +975,7 @@ namespace MoneyMap.Services var messageContent = responseObj.GetProperty("response").GetString(); _logger.LogInformation("Ollama: Successfully parsed response"); - return VisionApiResult.Success(CleanJsonResponse(messageContent)); + return VisionApiResult.Success(OpenAIToolUseHelper.CleanJsonResponse(messageContent)); } catch (TaskCanceledException ex) when (ex.InnerException is TimeoutException) { @@ -496,16 +988,6 @@ namespace MoneyMap.Services return VisionApiResult.Failure($"Ollama API error: {ex.Message}"); } } - - private static string CleanJsonResponse(string? content) - { - var trimmed = content?.Trim() ?? ""; - if (trimmed.StartsWith("```json")) - { - trimmed = trimmed.Replace("```json", "").Replace("```", "").Trim(); - } - return trimmed; - } } // Models for llama.cpp /v1/models endpoint