using Microsoft.Extensions.AI; using System.Text.Json; namespace Svrnty.Sample.AI; public sealed class OllamaClient(HttpClient http) : IChatClient { public ChatClientMetadata Metadata => new("ollama", new Uri("http://localhost:11434")); public async Task CompleteAsync( IList messages, ChatOptions? options = null, CancellationToken cancellationToken = default) { // Build messages array including tool results var ollamaMessages = messages.Select(m => new { role = m.Role.ToString().ToLower(), content = m.Text ?? string.Empty, tool_call_id = m.Contents.OfType().FirstOrDefault()?.CallId }).ToList(); // Build payload with optional tools var payload = new Dictionary { ["model"] = options?.ModelId ?? "qwen2.5-coder:7b", ["messages"] = ollamaMessages, ["stream"] = false }; // Add tools if provided if (options?.Tools is { Count: > 0 }) { payload["tools"] = options.Tools.Select(BuildToolDefinition).ToArray(); } var response = await http.PostAsJsonAsync("/api/chat", payload, cancellationToken); response.EnsureSuccessStatusCode(); var json = await response.Content.ReadFromJsonAsync(cancellationToken); var messageElement = json!.RootElement.GetProperty("message"); var content = messageElement.TryGetProperty("content", out var contentProp) ? contentProp.GetString() ?? "" : ""; var chatMessage = new ChatMessage(ChatRole.Assistant, content); // Parse tool calls - handle both OpenAI format and text-based format if (messageElement.TryGetProperty("tool_calls", out var toolCallsElement)) { // OpenAI-style tool_calls format foreach (var toolCall in toolCallsElement.EnumerateArray()) { var function = toolCall.GetProperty("function"); var functionName = function.GetProperty("name").GetString()!; var argumentsJson = function.GetProperty("arguments"); var arguments = ParseArguments(argumentsJson); chatMessage.Contents.Add(new FunctionCallContent( callId: Guid.NewGuid().ToString(), name: functionName, arguments: arguments )); } } else if (!string.IsNullOrWhiteSpace(content) && content.TrimStart().StartsWith("{")) { // Text-based function call format (some models like qwen2.5-coder return this) try { var functionCallJson = JsonDocument.Parse(content); if (functionCallJson.RootElement.TryGetProperty("name", out var nameProp) && functionCallJson.RootElement.TryGetProperty("arguments", out var argsProp)) { var functionName = nameProp.GetString()!; var arguments = ParseArguments(argsProp); chatMessage.Contents.Add(new FunctionCallContent( callId: Guid.NewGuid().ToString(), name: functionName, arguments: arguments )); } } catch { // Not a function call, just regular content } } return new ChatCompletion(chatMessage); } private static Dictionary ParseArguments(JsonElement argumentsJson) { var arguments = new Dictionary(); foreach (var prop in argumentsJson.EnumerateObject()) { arguments[prop.Name] = prop.Value.ValueKind switch { JsonValueKind.Number => prop.Value.GetDouble(), JsonValueKind.String => prop.Value.GetString(), JsonValueKind.True => true, JsonValueKind.False => false, _ => prop.Value.ToString() }; } return arguments; } private static object BuildToolDefinition(AITool tool) { var functionInfo = tool.GetType().GetProperty("Metadata")?.GetValue(tool) as AIFunctionMetadata ?? throw new InvalidOperationException("Tool must have Metadata property"); var parameters = new Dictionary { ["type"] = "object", ["properties"] = functionInfo.Parameters.ToDictionary( p => p.Name, p => new Dictionary { ["type"] = GetJsonType(p.ParameterType), ["description"] = p.Description ?? "" } ), ["required"] = functionInfo.Parameters .Where(p => p.IsRequired) .Select(p => p.Name) .ToArray() }; return new { type = "function", function = new { name = functionInfo.Name, description = functionInfo.Description ?? "", parameters } }; } private static string GetJsonType(Type type) { if (type == typeof(int) || type == typeof(long) || type == typeof(short)) return "integer"; if (type == typeof(float) || type == typeof(double) || type == typeof(decimal)) return "number"; if (type == typeof(bool)) return "boolean"; if (type == typeof(string)) return "string"; return "object"; } public IAsyncEnumerable CompleteStreamingAsync( IList messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => throw new NotImplementedException("Streaming not supported in MVP"); public TService? GetService(object? key = null) where TService : class => this as TService; public void Dispose() { } }