From d4f7ddf4dc930039f80c74fffd143a5be1118cdd Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 22:12:31 +0000 Subject: [PATCH 1/2] feat(provider): add LocalAI and Ollama providers - New SmartHopper.Providers.LocalAI for self-hosted LocalAI servers (OpenAI-compatible /v1/chat/completions). Defaults to http://localhost:8080/v1; users configure model and optional bearer token. - New SmartHopper.Providers.Ollama for local Ollama instances via the OpenAI-compatible endpoint. Defaults to http://localhost:11434/v1; exposes an extra 'seed' parameter for reproducible sampling. - Both providers support tools/function calling, SSE streaming, and JSON Schema structured outputs. - Wire new projects into SmartHopper.sln, Infrastructure InternalsVisibleTo list, Components.Test project references, manifest keywords, DEV.md provider/model tables, README provider list, and CHANGELOG. --- CHANGELOG.md | 2 + DEV.md | 6 +- README.md | 2 + SmartHopper.sln | 28 + .../SmartHopper.Components.Test.csproj | 2 + .../SmartHopper.Infrastructure.csproj | 2 + .../LocalAIJsonSchemaAdapter.cs | 91 +++ .../LocalAIProvider.Streaming.cs | 405 +++++++++++++ .../LocalAIProvider.cs | 559 +++++++++++++++++ .../LocalAIProviderFactory.cs | 41 ++ .../LocalAIProviderModels.cs | 53 ++ .../LocalAIProviderSettings.cs | 175 ++++++ .../Properties/Resources.Designer.cs | 75 +++ .../Properties/Resources.resx | 124 ++++ .../Resources/localai_icon.png | Bin 0 -> 161 bytes .../SmartHopper.Providers.LocalAI.csproj | 78 +++ .../OllamaJsonSchemaAdapter.cs | 91 +++ .../OllamaProvider.Streaming.cs | 405 +++++++++++++ .../OllamaProvider.cs | 571 ++++++++++++++++++ .../OllamaProviderFactory.cs | 41 ++ .../OllamaProviderModels.cs | 54 ++ .../OllamaProviderSettings.cs | 175 ++++++ .../Properties/Resources.Designer.cs | 75 +++ .../Properties/Resources.resx | 124 ++++ .../Resources/ollama_icon.png | Bin 0 -> 137 bytes .../SmartHopper.Providers.Ollama.csproj | 78 +++ yak-package/manifest.yml | 2 + 27 files changed, 3257 insertions(+), 2 deletions(-) create mode 100644 src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs create mode 100644 src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs create mode 100644 src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs create mode 100644 src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs create mode 100644 src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs create mode 100644 src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs create mode 100644 src/SmartHopper.Providers.LocalAI/Properties/Resources.Designer.cs create mode 100644 src/SmartHopper.Providers.LocalAI/Properties/Resources.resx create mode 100644 src/SmartHopper.Providers.LocalAI/Resources/localai_icon.png create mode 100644 src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj create mode 100644 src/SmartHopper.Providers.Ollama/OllamaJsonSchemaAdapter.cs create mode 100644 src/SmartHopper.Providers.Ollama/OllamaProvider.Streaming.cs create mode 100644 src/SmartHopper.Providers.Ollama/OllamaProvider.cs create mode 100644 src/SmartHopper.Providers.Ollama/OllamaProviderFactory.cs create mode 100644 src/SmartHopper.Providers.Ollama/OllamaProviderModels.cs create mode 100644 src/SmartHopper.Providers.Ollama/OllamaProviderSettings.cs create mode 100644 src/SmartHopper.Providers.Ollama/Properties/Resources.Designer.cs create mode 100644 src/SmartHopper.Providers.Ollama/Properties/Resources.resx create mode 100644 src/SmartHopper.Providers.Ollama/Resources/ollama_icon.png create mode 100644 src/SmartHopper.Providers.Ollama/SmartHopper.Providers.Ollama.csproj diff --git a/CHANGELOG.md b/CHANGELOG.md index c2b4433b8..f792abe5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **LocalAI provider** (`SmartHopper.Providers.LocalAI`): new built-in provider for self-hosted [LocalAI](https://localai.io/) instances. Talks to the standard OpenAI-compatible `/v1/chat/completions` endpoint, with full support for tools/function calling, streaming, JSON Schema structured outputs, and an `Authorization: Bearer` API key when one is configured. Settings include a required **Base URL** (default `http://localhost:8080/v1`) plus optional API Key, Model, Streaming, Max Tokens, and Temperature. The provider does not auto-discover models — users list whatever models they have installed locally. +- **Ollama provider** (`SmartHopper.Providers.Ollama`): new built-in provider for [Ollama](https://ollama.com/) running locally via its [OpenAI-compatible endpoint](https://ollama.com/blog/openai-compatibility). Mirrors the LocalAI capabilities (tools, streaming, JSON Schema, optional bearer token for reverse-proxy setups) plus an extra `seed` extra-descriptor for reproducible sampling. Settings include a required **Base URL** (default `http://localhost:11434/v1`) plus optional API Key, Model (e.g. `llama3.1`, `qwen2.5:14b`, `mistral:7b-instruct` — install with `ollama pull `), Streaming, Max Tokens, and Temperature. - **DEV.md provider model sync automation**: added `tools/Update-DevProviderModels.ps1` and a GitHub workflow that validates provider model documentation on PRs and opens sync PRs after protected-branch provider registry updates. - **README Trademark and Logo Usage Policy**: explicit policy clarifying that the SmartHopper name and logo are not licensed under LGPL, listing permitted uses (articles, tutorials, educational materials, references to the unmodified official plug-in) and uses requiring prior written permission (commercial bundling, forks, materials that may imply endorsement). diff --git a/DEV.md b/DEV.md index 6a7ff6b91..33c78c57d 100644 --- a/DEV.md +++ b/DEV.md @@ -173,8 +173,8 @@ SmartHopper currently supports the following AI providers and features: | Anthropic | ✅ Supported | Claude Console | Yes | No | No | Yes | Yes | Yes | No | ✅ Yes | | OpenRouter | ✅ Supported | OpenRouter | No | No (varies by routed model) | No | Varies | Varies | Varies | Varies | ❌ No | | Gemini | 🟠 Testing | Google AI Studio | Yes | Yes (thinking_level) | Yes | Yes | Yes | Yes | ✅ Yes | ✅ Yes | -| Ollama | ⚪ Planned | Local Ollama server | Planned | Planned | Planned | Planned | Planned | Planned | No | Planned | -| LocalAI | ⚪ Planned | LocalAI server | Planned | Planned | Planned | Planned | Planned | Planned | Planned | Planned | +| Ollama | 🟠 Testing | Local Ollama server (OpenAI-compatible) | Yes | Varies by model | Varies | Yes | Yes | Yes | No | ❌ No | +| LocalAI | 🟠 Testing | LocalAI server (OpenAI-compatible) | Yes | Varies by model | Varies | Yes | Yes | Yes | No | ❌ No | | Black Forest Labs | ⚪ Planned | Black Forest Labs API | Planned | No | No | Planned | No | No | Planned | Planned | | Stable Diffusion | ⚪ Planned | Local/API Stable Diffusion endpoint | Planned | No | No | Planned | No | No | Planned | Planned | @@ -192,7 +192,9 @@ The following table summarizes the models explicitly registered as defaults or v - `src/SmartHopper.Providers.Anthropic/AnthropicProviderModels.cs` - `src/SmartHopper.Providers.DeepSeek/DeepSeekProviderModels.cs` - `src/SmartHopper.Providers.Gemini/GeminiProviderModels.cs` +- `src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs` - `src/SmartHopper.Providers.MistralAI/MistralAIProviderModels.cs` +- `src/SmartHopper.Providers.Ollama/OllamaProviderModels.cs` - `src/SmartHopper.Providers.OpenAI/OpenAIProviderModels.cs` - `src/SmartHopper.Providers.OpenRouter/OpenRouterProviderModels.cs` diff --git a/README.md b/README.md index 4cda158f3..1b52335eb 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ SmartHopper brings a context‑aware AI assistant and a suite of AI‑powered co - ![Anthropic](src/SmartHopper.Providers.Anthropic/Resources/anthropic_icon.png) [Anthropic](https://anthropic.com/) - ![OpenRouter](src/SmartHopper.Providers.OpenRouter/Resources/openrouter_icon.png) [OpenRouter](https://openrouter.ai/) - ![Gemini](src/SmartHopper.Providers.Gemini/Resources/gemini_icon.png) [Google Gemini](https://ai.google.dev/) + - ![LocalAI](src/SmartHopper.Providers.LocalAI/Resources/localai_icon.png) [LocalAI](https://localai.io/) — self-hosted, OpenAI-compatible + - ![Ollama](src/SmartHopper.Providers.Ollama/Resources/ollama_icon.png) [Ollama](https://ollama.com/) — local, OpenAI-compatible - Open Source — and it will always be. diff --git a/SmartHopper.sln b/SmartHopper.sln index 506d4a01c..780ac8973 100644 --- a/SmartHopper.sln +++ b/SmartHopper.sln @@ -35,6 +35,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartHopper.Core.Grasshoppe EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartHopper.Core.Tests", "src\SmartHopper.Core.Tests\SmartHopper.Core.Tests.csproj", "{C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartHopper.Providers.LocalAI", "src\SmartHopper.Providers.LocalAI\SmartHopper.Providers.LocalAI.csproj", "{CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SmartHopper.Providers.Ollama", "src\SmartHopper.Providers.Ollama\SmartHopper.Providers.Ollama.csproj", "{E9280136-51DA-465A-8625-5E2C064D9946}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -224,6 +228,30 @@ Global {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|x64.Build.0 = Release|Any CPU {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|x86.ActiveCfg = Release|Any CPU {C7E24C95-ADF6-4DBA-BDB7-73CFB1291053}.Release|x86.Build.0 = Release|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Debug|x64.ActiveCfg = Debug|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Debug|x64.Build.0 = Debug|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Debug|x86.ActiveCfg = Debug|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Debug|x86.Build.0 = Debug|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Release|Any CPU.Build.0 = Release|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Release|x64.ActiveCfg = Release|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Release|x64.Build.0 = Release|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Release|x86.ActiveCfg = Release|Any CPU + {CBBB20C6-CE06-4EE7-A798-70CB83D88EB4}.Release|x86.Build.0 = Release|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Debug|x64.Build.0 = Debug|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Debug|x86.Build.0 = Debug|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Release|Any CPU.Build.0 = Release|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Release|x64.ActiveCfg = Release|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Release|x64.Build.0 = Release|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Release|x86.ActiveCfg = Release|Any CPU + {E9280136-51DA-465A-8625-5E2C064D9946}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/SmartHopper.Components.Test/SmartHopper.Components.Test.csproj b/src/SmartHopper.Components.Test/SmartHopper.Components.Test.csproj index c5057c214..9653dfe89 100644 --- a/src/SmartHopper.Components.Test/SmartHopper.Components.Test.csproj +++ b/src/SmartHopper.Components.Test/SmartHopper.Components.Test.csproj @@ -56,7 +56,9 @@ + + diff --git a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj index 16e6cfb77..6ce44a32a 100644 --- a/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj +++ b/src/SmartHopper.Infrastructure/SmartHopper.Infrastructure.csproj @@ -95,7 +95,9 @@ + + diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs b/src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs new file mode 100644 index 000000000..446a52974 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs @@ -0,0 +1,91 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.JsonSchemas; + +namespace SmartHopper.Providers.LocalAI +{ + /// + /// JSON schema adapter for LocalAI. + /// LocalAI follows the OpenAI schema requirements (object-root) when used with + /// the llama.cpp grammar-based JSON output. Non-object schemas are wrapped. + /// + internal sealed class LocalAIJsonSchemaAdapter : IJsonSchemaAdapter + { + /// + public string ProviderName => LocalAIProvider.NameValue; + + /// + public (JObject wrapped, SchemaWrapperInfo info) Wrap(JObject schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + var schemaType = schema["type"]?.ToString(); + + if (string.Equals(schemaType, "object", StringComparison.OrdinalIgnoreCase)) + { + return (schema, new SchemaWrapperInfo { IsWrapped = false, ProviderName = this.ProviderName }); + } + + if (string.Equals(schemaType, "array", StringComparison.OrdinalIgnoreCase)) + { + var wrapped = new JObject + { + ["type"] = "object", + ["properties"] = new JObject { ["items"] = schema }, + ["required"] = new JArray { "items" }, + ["additionalProperties"] = false, + }; + return (wrapped, new SchemaWrapperInfo { IsWrapped = true, WrapperType = "array", PropertyName = "items", ProviderName = this.ProviderName }); + } + + if (schemaType == "string" || schemaType == "number" || schemaType == "integer" || schemaType == "boolean") + { + var wrapped = new JObject + { + ["type"] = "object", + ["properties"] = new JObject { ["value"] = schema }, + ["required"] = new JArray { "value" }, + ["additionalProperties"] = false, + }; + return (wrapped, new SchemaWrapperInfo { IsWrapped = true, WrapperType = schemaType ?? "primitive", PropertyName = "value", ProviderName = this.ProviderName }); + } + + var generic = new JObject + { + ["type"] = "object", + ["properties"] = new JObject { ["data"] = schema }, + ["required"] = new JArray { "data" }, + ["additionalProperties"] = false, + }; + return (generic, new SchemaWrapperInfo { IsWrapped = true, WrapperType = "unknown", PropertyName = "data", ProviderName = this.ProviderName }); + } + + /// + public string Unwrap(string content, SchemaWrapperInfo info) + { + // Default service logic handles unwrap for object-root payloads + return content; + } + } +} diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs new file mode 100644 index 000000000..8824cbe54 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs @@ -0,0 +1,405 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.Core; +using SmartHopper.Infrastructure.AICall.Core.Base; +using SmartHopper.Infrastructure.AICall.Core.Interactions; +using SmartHopper.Infrastructure.AICall.Core.Requests; +using SmartHopper.Infrastructure.AICall.Core.Returns; +using SmartHopper.Infrastructure.AICall.Metrics; +using SmartHopper.Infrastructure.AIProviders; +using SmartHopper.Infrastructure.Streaming; + +namespace SmartHopper.Providers.LocalAI +{ + /// + /// Streaming half of . + /// + public sealed partial class LocalAIProvider + { + /// + protected override IStreamingAdapter CreateStreamingAdapter() + { + return new LocalAIStreamingAdapter(this); + } + + /// + /// Provider-scoped streaming adapter for LocalAI Chat Completions SSE. + /// LocalAI follows the OpenAI streaming protocol (data: {...}\n\n + [DONE]). + /// + private sealed class LocalAIStreamingAdapter : AIProviderStreamingAdapter, IStreamingAdapter + { + public LocalAIStreamingAdapter(LocalAIProvider provider) + : base(provider) + { + } + + public async IAsyncEnumerable StreamAsync( + AIRequestCall request, + StreamingOptions options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (request == null) + { + yield break; + } + + request = this.Prepare(request); + + if (!string.Equals(request.Endpoint, "/chat/completions", StringComparison.Ordinal)) + { + var unsupported = new AIReturn(); + unsupported.CreateProviderError("Streaming is only supported for /chat/completions in this adapter.", request); + yield return unsupported; + yield break; + } + + JObject body; + try + { + body = JObject.Parse(this.Provider.Encode(request)); + } + catch + { + body = new JObject(); + } + + body["stream"] = true; + + var fullUrl = this.BuildFullUrl(request.Endpoint); + + using var httpClient = this.CreateHttpClient(); + AIReturn? authError = null; + try + { + var apiKey = ((LocalAIProvider)this.Provider).GetApiKey(); + this.ApplyAuthentication(httpClient, request.Authentication, apiKey); + } + catch (Exception ex) + { + authError = new AIReturn(); + authError.CreateProviderError(ex.Message, request); + } + + if (authError != null) + { + yield return authError; + yield break; + } + + using var httpRequest = this.CreateSsePost(fullUrl, body.ToString(), "application/json"); + + HttpResponseMessage response; + AIReturn? sendError = null; + try + { + response = await this.SendForStreamAsync(httpClient, httpRequest, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + sendError = new AIReturn(); + sendError.CreateNetworkError(ex.InnerException?.Message ?? ex.Message, request); + response = null!; + } + + if (sendError != null) + { + yield return sendError; + yield break; + } + + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var (message, isNetworkLike) = AIProvider.ClassifyHttpError((int)response.StatusCode, response.ReasonPhrase, content, this.Provider.Name); + var err = new AIReturn(); + if (isNetworkLike) + { + err.CreateNetworkError(message, request); + } + else + { + err.CreateProviderError(message, request); + } + + yield return err; + yield break; + } + + var buffer = new StringBuilder(); + var lastEmit = DateTime.UtcNow; + var firstChunk = true; + string finalFinishReason = string.Empty; + int promptTokens = 0; + int completionTokens = 0; + + var assistantAggregate = new AIInteractionText + { + Agent = AIAgent.Assistant, + Content = string.Empty, + Metrics = new AIMetrics { Provider = this.Provider.Name, Model = request.Model }, + }; + + // Tool call accumulation (index -> partial) + var toolCalls = new Dictionary(); + + async IAsyncEnumerable EmitAsync(string text, bool streamingStatus) + { + if (string.IsNullOrEmpty(text)) yield break; + + assistantAggregate.AppendDelta(contentDelta: text); + + var snapshot = new AIInteractionText + { + Agent = assistantAggregate.Agent, + Content = assistantAggregate.Content, + Metrics = new AIMetrics + { + Provider = assistantAggregate.Metrics.Provider, + Model = assistantAggregate.Metrics.Model, + FinishReason = assistantAggregate.Metrics.FinishReason, + InputTokensCached = assistantAggregate.Metrics.InputTokensCached, + InputTokensPrompt = assistantAggregate.Metrics.InputTokensPrompt, + OutputTokensReasoning = assistantAggregate.Metrics.OutputTokensReasoning, + OutputTokensGeneration = assistantAggregate.Metrics.OutputTokensGeneration, + CompletionTime = assistantAggregate.Metrics.CompletionTime, + }, + }; + + var delta = new AIReturn + { + Request = request, + Status = streamingStatus ? AICallStatus.Streaming : AICallStatus.Processing, + }; + delta.SetBody(new List { snapshot }); + yield return delta; + await Task.Yield(); + } + + async Task> FlushAsync(bool force) + { + var results = new List(); + if (buffer.Length == 0) return results; + var elapsed = (DateTime.UtcNow - lastEmit).TotalMilliseconds; + if (force || !options.CoalesceTokens || buffer.Length >= options.PreferredChunkSize || elapsed >= options.CoalesceDelayMs) + { + var text = buffer.ToString(); + buffer.Clear(); + lastEmit = DateTime.UtcNow; + await foreach (var d in EmitAsync(text, streamingStatus: true).WithCancellation(cancellationToken)) + { + results.Add(d); + } + } + + return results; + } + + { + var initial = new AIReturn { Request = request, Status = AICallStatus.Processing }; + initial.SetBody(new List()); + yield return initial; + } + + var idleTimeout = TimeSpan.FromSeconds((double)(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : TimeoutDefaults.DefaultTimeoutSeconds)); + await foreach (var data in this.ReadSseDataAsync( + response, + idleTimeout, + null, + cancellationToken).WithCancellation(cancellationToken)) + { + JObject parsed; + try + { + parsed = JObject.Parse(data); + } + catch + { + continue; + } + + var choices = parsed["choices"] as JArray; + var choice = choices?.FirstOrDefault() as JObject; + var delta = choice?["delta"] as JObject; + var finishReason = choice?["finish_reason"]?.ToString(); + bool hasFinish = !string.IsNullOrEmpty(finishReason); + if (hasFinish) finalFinishReason = finishReason; + + var usage = parsed["usage"] as JObject; + if (usage != null) + { + var pt = usage["prompt_tokens"]?.Value(); + var ct = usage["completion_tokens"]?.Value(); + if (pt.HasValue) promptTokens = pt.Value; + if (ct.HasValue) completionTokens = ct.Value; + + assistantAggregate.AppendDelta(metricsDelta: new AIMetrics + { + Provider = this.Provider.Name, + Model = request.Model, + InputTokensPrompt = pt ?? 0, + OutputTokensGeneration = ct ?? 0, + }); + } + + var contentDelta = delta?["content"]?.ToString(); + if (!string.IsNullOrEmpty(contentDelta)) + { + buffer.Append(contentDelta); + var emitted = await FlushAsync(force: firstChunk).ConfigureAwait(false); + if (firstChunk) firstChunk = false; + foreach (var d in emitted) + { + yield return d; + } + } + + var tcArray = delta?["tool_calls"] as JArray; + if (tcArray != null) + { + foreach (var t in tcArray.OfType()) + { + var idx = t["index"]?.Value() ?? 0; + if (!toolCalls.TryGetValue(idx, out var entry)) + { + entry = (Id: string.Empty, Name: string.Empty, Args: new StringBuilder()); + toolCalls[idx] = entry; + } + + var idVal = t["id"]?.ToString(); + if (!string.IsNullOrEmpty(idVal)) entry.Id = idVal; + + var func = t["function"] as JObject; + if (func != null) + { + var name = func["name"]?.ToString(); + if (!string.IsNullOrEmpty(name)) entry.Name = name; + var args = func["arguments"]?.ToString(); + if (!string.IsNullOrEmpty(args)) entry.Args.Append(args); + } + + toolCalls[idx] = entry; + } + + var emittedTc = await FlushAsync(force: true).ConfigureAwait(false); + foreach (var d in emittedTc) + { + yield return d; + } + + var interactions = new List(); + foreach (var kv in toolCalls.OrderBy(k => k.Key)) + { + var (id, name, argsSb) = kv.Value; + JObject? argsObj = null; + var argsStr = argsSb.ToString(); + try + { + if (!string.IsNullOrWhiteSpace(argsStr)) + { + argsObj = JObject.Parse(argsStr); + } + } + catch + { + // Partial JSON, ignore + } + + interactions.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj }); + } + + var tcDelta = new AIReturn { Request = request, Status = AICallStatus.CallingTools }; + tcDelta.SetBody(interactions); + yield return tcDelta; + } + } + + var finalEmitted = await FlushAsync(force: true).ConfigureAwait(false); + foreach (var d in finalEmitted) + { + yield return d; + } + + var final = new AIReturn + { + Request = request, + Status = AICallStatus.Finished, + }; + + assistantAggregate.AppendDelta(metricsDelta: new AIMetrics + { + FinishReason = string.IsNullOrEmpty(finalFinishReason) ? "stop" : finalFinishReason, + }); + + var finalBuilder = AIBodyBuilder.Create(); + if (!string.IsNullOrEmpty(assistantAggregate.Content)) + { + var finalSnapshot = new AIInteractionText + { + Agent = assistantAggregate.Agent, + Content = assistantAggregate.Content, + Metrics = new AIMetrics + { + Provider = assistantAggregate.Metrics.Provider, + Model = assistantAggregate.Metrics.Model, + FinishReason = assistantAggregate.Metrics.FinishReason, + InputTokensCached = assistantAggregate.Metrics.InputTokensCached, + InputTokensPrompt = assistantAggregate.Metrics.InputTokensPrompt, + OutputTokensReasoning = assistantAggregate.Metrics.OutputTokensReasoning, + OutputTokensGeneration = assistantAggregate.Metrics.OutputTokensGeneration, + CompletionTime = assistantAggregate.Metrics.CompletionTime, + }, + }; + finalBuilder.Add(finalSnapshot, markAsNew: false); + } + + foreach (var kv in toolCalls.OrderBy(k => k.Key)) + { + var (id, name, argsSb) = kv.Value; + JObject? argsObj = null; + var argsStr = argsSb.ToString(); + try + { + if (!string.IsNullOrWhiteSpace(argsStr)) + { + argsObj = JObject.Parse(argsStr); + } + } + catch + { + // Partial JSON + } + + finalBuilder.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj }, markAsNew: false); + } + + final.SetBody(finalBuilder.Build()); + yield return final; + } + } + } +} diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs new file mode 100644 index 000000000..de4e72801 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs @@ -0,0 +1,559 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.Core; +using SmartHopper.Infrastructure.AICall.Core.Base; +using SmartHopper.Infrastructure.AICall.Core.Interactions; +using SmartHopper.Infrastructure.AICall.Core.Requests; +using SmartHopper.Infrastructure.AICall.JsonSchemas; +using SmartHopper.Infrastructure.AICall.Metrics; +using SmartHopper.Infrastructure.AIProviders; +using SmartHopper.Infrastructure.Diagnostics; +using SmartHopper.Infrastructure.Streaming; + +namespace SmartHopper.Providers.LocalAI +{ + /// + /// LocalAI provider implementation. + /// Targets self-hosted LocalAI instances (https://localai.io/) through the + /// OpenAI-compatible /v1/chat/completions endpoint. + /// + public sealed partial class LocalAIProvider : AIProvider + { + /// + /// The name of the provider. Used by the UI for provider selection. + /// + public static readonly string NameValue = "LocalAI"; + + /// + /// Fallback default server URL when no Base URL has been configured. + /// LocalAI defaults to port 8080 on localhost. + /// + public static readonly Uri FallbackServerUrl = new ("http://localhost:8080/v1"); + + /// + /// Initializes a new instance of the class. + /// Private constructor to enforce singleton pattern. + /// + private LocalAIProvider() + { + this.Models = new LocalAIProviderModels(this); + + // Register provider-specific JSON schema adapter (object-root wrapper, OpenAI-style) + JsonSchemaAdapterRegistry.Register(new LocalAIJsonSchemaAdapter()); + } + + /// + public override string Name => NameValue; + + /// + /// Gets the server URL configured for this provider. + /// Reads the user-provided ServerUrl setting and falls back to + /// when the setting is empty or not a valid absolute HTTP(S) URL. + /// + public override Uri DefaultServerUrl + { + get + { + var configured = this.GetServerUrlSetting(); + if (!string.IsNullOrWhiteSpace(configured) + && Uri.TryCreate(configured, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return uri; + } + + return FallbackServerUrl; + } + } + + /// + public override bool IsEnabled => true; + + /// + /// Helper to retrieve the configured API key for this provider. + /// Exposed to the nested streaming adapter to avoid protected access issues. + /// + internal string GetApiKey() + { + return this.GetSetting("ApiKey"); + } + + /// + /// Helper to retrieve the configured Base URL setting (raw string). + /// + internal string GetServerUrlSetting() + { + return this.GetSetting("ServerUrl"); + } + + /// + public override Image Icon + { + get + { + var iconBytes = Properties.Resources.localai_icon; + using (var ms = new MemoryStream(iconBytes)) + { + return new Bitmap(ms); + } + } + } + + /// + public override AIRequestCall PreCall(AIRequestCall request) + { + // First do the base PreCall + request = base.PreCall(request); + + // Setup HTTP method, content type, and authentication. + request.HttpMethod = "POST"; + request.ContentType = "application/json"; + + // LocalAI API keys are optional (typically configured server-side). + // Use bearer auth only when the user has supplied a key; otherwise no auth. + var apiKey = this.GetApiKey(); + request.Authentication = string.IsNullOrWhiteSpace(apiKey) ? "none" : "bearer"; + + // LocalAI exposes the OpenAI-compatible chat completions endpoint + request.Endpoint = "/chat/completions"; + + return request; + } + + /// + public override string Encode(AIRequestCall request) + { + if (request.HttpMethod == "GET" || request.HttpMethod == "DELETE") + { + return "GET and DELETE requests do not use a request body"; + } + + var p = request.Parameters; + + int maxTokens = p?.MaxTokens ?? this.GetSetting("MaxTokens"); + double temperature = p?.Temperature ?? this.GetSetting("Temperature"); + string? toolFilter = request.Body.ToolFilter; + + Debug.WriteLine($"[LocalAI] Encode - Model: {request.Model}, MaxTokens: {maxTokens}"); + + // Simple sequential encoding (same approach as OpenAI/MistralAI/DeepSeek providers). + var convertedMessages = new JArray(); + + // Merge System and Summary interactions before encoding + var mergedInteractions = this.MergeSystemAndSummary(request.Body.Interactions); + + foreach (var interaction in mergedInteractions) + { + try + { + var token = this.EncodeToJToken(interaction); + if (token == null) + { + continue; + } + + var role = token["role"]?.ToString(); + if (string.IsNullOrEmpty(role)) + { + continue; + } + + convertedMessages.Add(token); + } + catch (Exception ex) + { + Debug.WriteLine($"[LocalAI] Warning: Could not encode interaction: {ex.Message}"); + } + } + + // Build request body + var requestBody = new JObject + { + ["model"] = request.Model, + ["messages"] = convertedMessages, + ["max_tokens"] = maxTokens, + ["temperature"] = temperature, + }; + + // Apply optional parameters from extras only + if (p?.Extras != null) + { + if (p.Extras.TryGetValue("top_p", out var topPToken) && topPToken != null) + { + requestBody["top_p"] = topPToken.Value(); + } + + if (p.Extras.TryGetValue("top_k", out var topKToken) && topKToken != null) + { + requestBody["top_k"] = topKToken.Value(); + } + + if (p.Extras.TryGetValue("presence_penalty", out var ppToken) && ppToken != null) + { + requestBody["presence_penalty"] = ppToken; + } + + if (p.Extras.TryGetValue("frequency_penalty", out var fpToken) && fpToken != null) + { + requestBody["frequency_penalty"] = fpToken; + } + } + + // Add JSON response format if schema is provided (centralized wrapping) + if (!string.IsNullOrWhiteSpace(request.Body.JsonOutputSchema)) + { + try + { + var svc = JsonSchemaService.Instance; + var schemaObj = JObject.Parse(request.Body.JsonOutputSchema); + var (wrappedSchema, wrapperInfo) = svc.WrapForProvider(schemaObj, this.Name); + svc.SetCurrentWrapperInfo(wrapperInfo); + Debug.WriteLine($"[LocalAI] Schema wrapper info stored (central): IsWrapped={wrapperInfo.IsWrapped}, Type={wrapperInfo.WrapperType}, Property={wrapperInfo.PropertyName}"); + + // LocalAI llama.cpp backends honor json_object response_format + requestBody["response_format"] = new JObject { ["type"] = "json_object" }; + + // Add a system guidance message including the wrapped schema + var systemMessage = new JObject + { + ["role"] = "system", + ["content"] = "The response must be a valid JSON object that strictly follows this schema: " + wrappedSchema.ToString(Newtonsoft.Json.Formatting.None), + }; + convertedMessages.Insert(0, systemMessage); + } + catch (Exception ex) + { + Debug.WriteLine($"[LocalAI] Failed to parse/handle JSON schema: {ex.Message}"); + JsonSchemaService.Instance.SetCurrentWrapperInfo(new SchemaWrapperInfo { IsWrapped = false }); + requestBody["response_format"] = new JObject { ["type"] = "text" }; + } + } + else + { + JsonSchemaService.Instance.SetCurrentWrapperInfo(new SchemaWrapperInfo { IsWrapped = false }); + requestBody["response_format"] = new JObject { ["type"] = "text" }; + } + + // Add tools if requested + if (!string.IsNullOrWhiteSpace(toolFilter)) + { + var tools = this.GetFormattedTools(toolFilter); + if (tools != null && tools.Count > 0) + { + requestBody["tools"] = tools; + + // Forced tool call uses OpenAI-compatible tool_choice with function name + if (request.ForceToolCall && !string.IsNullOrWhiteSpace(request.ForceToolName)) + { + requestBody["tool_choice"] = new JObject + { + ["type"] = "function", + ["function"] = new JObject { ["name"] = request.ForceToolName, }, + }; + Debug.WriteLine($"[LocalAI] Forcing tool call: {request.ForceToolName}"); + } + else + { + requestBody["tool_choice"] = "auto"; + } + } + } + + return requestBody.ToString(); + } + + /// + public override string Encode(IAIInteraction interaction) + { + try + { + var token = this.EncodeToJToken(interaction); + return token?.ToString() ?? string.Empty; + } + catch (Exception ex) + { + Debug.WriteLine($"[LocalAI] Encode(IAIInteraction) error: {ex.Message}"); + return string.Empty; + } + } + + /// + /// Converts a single interaction into a LocalAI/OpenAI-compatible message object. + /// Returns null for interactions that should not be sent (e.g., UI-only diagnostics). + /// + private JToken? EncodeToJToken(IAIInteraction interaction) + { + if (interaction == null) + { + return null; + } + + // UI-only diagnostics must not be sent to providers + if (interaction is AIInteractionRuntimeMessage) + { + return null; + } + + // Map role + string role; + switch (interaction.Agent) + { + case AIAgent.System: + case AIAgent.Context: + role = "system"; + break; + case AIAgent.User: + role = "user"; + break; + case AIAgent.Assistant: + case AIAgent.ToolCall: + role = "assistant"; + break; + case AIAgent.ToolResult: + role = "tool"; + break; + default: + return null; + } + + var messageObj = new JObject { ["role"] = role }; + + if (interaction is AIInteractionText textInteraction) + { + messageObj["content"] = textInteraction.Content ?? string.Empty; + } + else if (interaction is AIInteractionToolResult toolResultInteraction) + { + messageObj["content"] = toolResultInteraction.Result != null + ? JsonConvert.SerializeObject(toolResultInteraction.Result, Formatting.None) + : string.Empty; + + if (!string.IsNullOrWhiteSpace(toolResultInteraction.Id)) + { + messageObj["tool_call_id"] = toolResultInteraction.Id; + } + + if (!string.IsNullOrWhiteSpace(toolResultInteraction.Name)) + { + messageObj["name"] = toolResultInteraction.Name; + } + } + else if (interaction is AIInteractionToolCall toolCallInteraction) + { + var toolCallObj = new JObject + { + ["id"] = toolCallInteraction.Id ?? string.Empty, + ["type"] = "function", + ["function"] = new JObject + { + ["name"] = toolCallInteraction.Name ?? string.Empty, + ["arguments"] = toolCallInteraction.Arguments?.ToString() ?? "{}", + }, + }; + messageObj["tool_calls"] = new JArray { toolCallObj }; + messageObj["content"] = string.Empty; + } + else if (interaction is AIInteractionImage imageInteraction) + { + // LocalAI vision support depends on backend; fall back to the original prompt + messageObj["content"] = imageInteraction.OriginalPrompt ?? string.Empty; + } + else + { + messageObj["content"] = string.Empty; + } + + return messageObj; + } + + /// + public override List Decode(JObject response) + { + var interactions = new List(); + if (response == null) + { + return interactions; + } + + try + { + // OpenAI-compatible error envelope: {"error": {"message": "...", "type": "..."}} + if (response["error"] is JObject errorObj) + { + var errMsg = errorObj["message"]?.ToString() + ?? errorObj["type"]?.ToString() + ?? "Provider returned an error"; + Debug.WriteLine($"[LocalAI] Decode: provider error in response body: {errMsg}"); + interactions.Add(new AIInteractionRuntimeMessage { Severity = SHRuntimeMessageSeverity.Error, Content = errMsg }); + return interactions; + } + + var choices = response["choices"] as JArray; + var firstChoice = choices?.FirstOrDefault() as JObject; + var message = firstChoice?["message"] as JObject; + if (message == null) + { + Debug.WriteLine("[LocalAI] Decode: No message found in response"); + return interactions; + } + + // Robust content extraction: handle string, array of parts, or object content + string content; + var contentToken = message["content"]; + if (contentToken is JArray contentParts && contentParts.Count > 0) + { + var texts = new List(); + foreach (var part in contentParts) + { + var text = part?["text"]?.ToString() + ?? part?["content"]?.ToString() + ?? part?.ToString(Newtonsoft.Json.Formatting.None) + ?? string.Empty; + if (!string.IsNullOrWhiteSpace(text)) + { + texts.Add(text); + } + } + + content = string.Join("\n", texts); + } + else + { + content = contentToken?.ToString() ?? string.Empty; + } + + // Apply centralized unwrapping using stored wrapper info + var wrapperInfo = JsonSchemaService.Instance.GetCurrentWrapperInfo(); + if (wrapperInfo != null && wrapperInfo.IsWrapped) + { + Debug.WriteLine($"[LocalAI] Unwrapping response content using wrapper info (central): Type={wrapperInfo.WrapperType}, Property={wrapperInfo.PropertyName}"); + content = JsonSchemaService.Instance.Unwrap(content, wrapperInfo); + } + + // LocalAI may also surface reasoning text via reasoning_content (see openai-functions docs) + var reasoning = message["reasoning_content"]?.ToString(); + + var interaction = new AIInteractionText(); + interaction.SetResult( + agent: AIAgent.Assistant, + content: content, + reasoning: string.IsNullOrWhiteSpace(reasoning) ? null : reasoning); + interaction.Metrics = this.DecodeMetrics(response); + interactions.Add(interaction); + + // Add an AIInteractionToolCall for each tool call + if (message["tool_calls"] is JArray tcs && tcs.Count > 0) + { + foreach (JObject tc in tcs.OfType()) + { + var function = tc["function"] as JObject; + var argumentsStr = function?["arguments"]?.ToString() ?? "{}"; + JObject? argumentsObj = null; + try + { + if (!string.IsNullOrWhiteSpace(argumentsStr)) + { + argumentsObj = JObject.Parse(argumentsStr); + } + } + catch + { + // Some local backends emit non-JSON arguments; pass them through as-is + } + + var toolCall = new AIInteractionToolCall + { + Id = tc["id"]?.ToString(), + Name = function?["name"]?.ToString(), + Arguments = argumentsObj, + Reasoning = string.IsNullOrWhiteSpace(reasoning) ? null : reasoning, + }; + interactions.Add(toolCall); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[LocalAI] Decode error: {ex.Message}"); + } + + return interactions; + } + + /// + /// Decodes the metrics from the response (token usage + finish reason). + /// + private AIMetrics DecodeMetrics(JObject response) + { + var choices = response["choices"] as JArray; + var firstChoice = choices?.FirstOrDefault() as JObject; + var usage = response["usage"] as JObject; + + var metrics = new AIMetrics + { + Provider = this.Name, + FinishReason = firstChoice?["finish_reason"]?.ToString() ?? string.Empty, + InputTokensPrompt = usage?["prompt_tokens"]?.Value() ?? 0, + OutputTokensGeneration = usage?["completion_tokens"]?.Value() ?? 0, + }; + + return metrics; + } + + /// + public override IEnumerable GetExtraDescriptors() + { + return new[] + { + new AIExtraDescriptor( + "top_p", + "Top P", + "Nucleus sampling parameter (0.0–1.0). Lower values make output more focused; higher values more diverse. Leave empty to use the backend default.", + typeof(double), + null), + new AIExtraDescriptor( + "top_k", + "Top K", + "Limits the next-token candidate pool to the K most likely tokens. Supported by most llama.cpp backends. Leave empty to use the backend default.", + typeof(int), + null), + new AIExtraDescriptor( + "presence_penalty", + "Presence Penalty", + "Penalizes tokens already present in the text (-2.0 to 2.0). Positive values encourage new topics.", + typeof(double), + null), + new AIExtraDescriptor( + "frequency_penalty", + "Frequency Penalty", + "Penalizes frequent tokens (-2.0 to 2.0). Positive values reduce repetition.", + typeof(double), + null), + }; + } + } +} diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs new file mode 100644 index 000000000..fcbbea7a0 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs @@ -0,0 +1,41 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using SmartHopper.Infrastructure.AIProviders; + +namespace SmartHopper.Providers.LocalAI +{ + /// + /// Factory class for creating instances of the LocalAI provider and its settings. + /// This class is discovered by the ProviderManager through reflection. + /// + public class LocalAIProviderFactory : IAIProviderFactory + { + /// + public IAIProvider CreateProvider() + { + return LocalAIProvider.Instance; + } + + /// + public IAIProviderSettings CreateProviderSettings() + { + return new LocalAIProviderSettings(LocalAIProvider.Instance); + } + } +} diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs new file mode 100644 index 000000000..ec75487f7 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs @@ -0,0 +1,53 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System.Collections.Generic; +using System.Threading.Tasks; +using SmartHopper.Infrastructure.AIModels; +using SmartHopper.Infrastructure.AIProviders; + +namespace SmartHopper.Providers.LocalAI +{ + /// + /// LocalAI provider-specific model management implementation. + /// + /// + /// LocalAI is self-hosted and the available models depend entirely on what the + /// user has installed on their instance. We therefore do not ship a static catalog + /// of curated models. The user supplies the model name through the provider's + /// Model setting; we return an empty list and rely on the user-supplied name. + /// + public class LocalAIProviderModels : AIProviderModels + { + /// + /// Initializes a new instance of the class. + /// + /// The LocalAI provider instance. + public LocalAIProviderModels(LocalAIProvider provider) + : base(provider) + { + } + + /// + public override Task> RetrieveModels() + { + // Models are user-managed in LocalAI; no static catalog. + return Task.FromResult(new List()); + } + } +} diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs new file mode 100644 index 000000000..e7e469b22 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs @@ -0,0 +1,175 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using SmartHopper.Infrastructure.AIProviders; +using SmartHopper.Infrastructure.Dialogs; +using SmartHopper.Infrastructure.Settings; + +namespace SmartHopper.Providers.LocalAI +{ + /// + /// Settings implementation for the LocalAI provider. + /// Exposes a configurable Base URL pointing at a self-hosted LocalAI instance, + /// plus an optional API key for installations that have authentication enabled. + /// + public class LocalAIProviderSettings : AIProviderSettings + { + private new readonly IAIProvider provider; + + /// + /// Initializes a new instance of the class. + /// + /// The provider associated with these settings. + public LocalAIProviderSettings(IAIProvider provider) + : base(provider) + { + this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + public override IEnumerable GetSettingDescriptors() + { + return new[] + { + new SettingDescriptor + { + Name = "ServerUrl", + DisplayName = "Base URL", + Description = "Base URL of your LocalAI server, including the API version prefix (e.g. http://localhost:8080/v1).", + Type = typeof(string), + DefaultValue = LocalAIProvider.FallbackServerUrl.ToString(), + }, + new SettingDescriptor + { + Name = "ApiKey", + DisplayName = "API Key", + Description = "Optional API key for LocalAI installations that have authentication enabled. Leave empty for unauthenticated local instances.", + IsSecret = true, + Type = typeof(string), + }, + new SettingDescriptor + { + Name = "Model", + DisplayName = "Model", + Description = "Model name as registered in your LocalAI instance (e.g. 'gpt-3.5-turbo', 'llama-3-8b-instruct').", + Type = typeof(string), + }.Apply(d => d.SetLazyDefault(() => this.provider.GetDefaultModel())), + new SettingDescriptor + { + Name = "EnableStreaming", + Type = typeof(bool), + DefaultValue = true, + DisplayName = "Enable Streaming", + Description = "Allow streaming responses for this provider. When enabled, you will receive the response as it is generated.", + }, + new SettingDescriptor + { + Name = "MaxTokens", + DisplayName = "Max Tokens", + Description = "Maximum number of tokens to generate.", + Type = typeof(int), + DefaultValue = 2000, + ControlParams = new NumericSettingDescriptorControl + { + UseSlider = false, + Min = 1, + Max = 32768, + Step = 1, + }, + }, + new SettingDescriptor + { + Name = "Temperature", + Type = typeof(string), + DefaultValue = "0.5", + DisplayName = "Temperature", + Description = "Controls randomness (0.0–2.0). Higher values produce more diverse output; lower values are more focused and deterministic.", + }, + }; + } + + /// + public override bool ValidateSettings(Dictionary settings) + { + Debug.WriteLine($"[LocalAI] ValidateSettings called. Settings null? {settings == null}"); + + if (settings == null) + { + return false; + } + + const bool showErrorDialogs = true; + + // Validate Base URL when supplied: must parse as an absolute HTTP(S) URI. + if (settings.TryGetValue("ServerUrl", out var serverUrlObj) && serverUrlObj != null) + { + var serverUrl = serverUrlObj.ToString(); + if (!string.IsNullOrWhiteSpace(serverUrl)) + { + if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var parsed) + || (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + { + if (showErrorDialogs) + { + StyledMessageDialog.ShowError( + "Base URL must be an absolute HTTP or HTTPS URL (for example http://localhost:8080/v1).", + "Validation Error"); + } + + return false; + } + } + } + + // MaxTokens must be a positive integer + if (settings.TryGetValue("MaxTokens", out var maxTokensObj) && maxTokensObj != null) + { + if (!int.TryParse(maxTokensObj.ToString(), out var parsedMaxTokens) || parsedMaxTokens <= 0) + { + if (showErrorDialogs) + { + StyledMessageDialog.ShowError("Max tokens must be a positive number.", "Validation Error"); + } + + return false; + } + } + + // Temperature must be in [0.0, 2.0] + if (settings.TryGetValue("Temperature", out var temperatureObj) && temperatureObj != null) + { + if (!double.TryParse(temperatureObj.ToString(), out var parsedTemperature) + || parsedTemperature < 0.0 + || parsedTemperature > 2.0) + { + if (showErrorDialogs) + { + StyledMessageDialog.ShowError("Temperature must be between 0.0 and 2.0.", "Validation Error"); + } + + return false; + } + } + + return true; + } + } +} diff --git a/src/SmartHopper.Providers.LocalAI/Properties/Resources.Designer.cs b/src/SmartHopper.Providers.LocalAI/Properties/Resources.Designer.cs new file mode 100644 index 000000000..c909308c9 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/Properties/Resources.Designer.cs @@ -0,0 +1,75 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace SmartHopper.Providers.LocalAI.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SmartHopper.Providers.LocalAI.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized resource of type System.Byte[]. + /// + internal static byte[] localai_icon { + get { + object obj = ResourceManager.GetObject("localai_icon", resourceCulture); + return ((byte[])(obj)); + } + } + } +} diff --git a/src/SmartHopper.Providers.LocalAI/Properties/Resources.resx b/src/SmartHopper.Providers.LocalAI/Properties/Resources.resx new file mode 100644 index 000000000..6f27e6bb2 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/Properties/Resources.resx @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\Resources\localai_icon.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/src/SmartHopper.Providers.LocalAI/Resources/localai_icon.png b/src/SmartHopper.Providers.LocalAI/Resources/localai_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..541a1b5ee4c950083d788eba1df676199a6aca98 GIT binary patch literal 161 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`8J;eVAr*6y6C^SYoH%gcfWqbC z=k}Jf=1*B+u<3vWd(afmOK%LG>oJ3X@U9lGLr#-;4jD9r81V2U98~L&n@}!xL^z+p z?q~&X!%^*(4T7^3)=l}j#D&$__Gn{AlG4UXrkkoFyBQd6-WR*QdqMJVpq&h!u6{1- HoD!M<`UE&q literal 0 HcmV?d00001 diff --git a/src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj b/src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj new file mode 100644 index 000000000..4fc8b8848 --- /dev/null +++ b/src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj @@ -0,0 +1,78 @@ + + + + + net7.0-windows;net7.0 + true + NU1701;NETSDK1086;SA1124;SA1200 + true + true + + + + SmartHopper LocalAI Provider + LocalAI provider for SmartHopper. Connects to a self-hosted LocalAI instance via its OpenAI-compatible API. + + + + + $(SolutionDir)bin/$(SolutionVersion)/$(Configuration) + + + + + + + + + true + $(DefineConstants);WINDOWS + + + + + + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + diff --git a/src/SmartHopper.Providers.Ollama/OllamaJsonSchemaAdapter.cs b/src/SmartHopper.Providers.Ollama/OllamaJsonSchemaAdapter.cs new file mode 100644 index 000000000..3374767ae --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/OllamaJsonSchemaAdapter.cs @@ -0,0 +1,91 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.JsonSchemas; + +namespace SmartHopper.Providers.Ollama +{ + /// + /// JSON schema adapter for Ollama. + /// Ollama's OpenAI-compatible endpoint accepts a JSON object response_format; + /// non-object root schemas are wrapped into an object to remain compatible. + /// + internal sealed class OllamaJsonSchemaAdapter : IJsonSchemaAdapter + { + /// + public string ProviderName => OllamaProvider.NameValue; + + /// + public (JObject wrapped, SchemaWrapperInfo info) Wrap(JObject schema) + { + if (schema is null) + { + throw new ArgumentNullException(nameof(schema)); + } + + var schemaType = schema["type"]?.ToString(); + + if (string.Equals(schemaType, "object", StringComparison.OrdinalIgnoreCase)) + { + return (schema, new SchemaWrapperInfo { IsWrapped = false, ProviderName = this.ProviderName }); + } + + if (string.Equals(schemaType, "array", StringComparison.OrdinalIgnoreCase)) + { + var wrapped = new JObject + { + ["type"] = "object", + ["properties"] = new JObject { ["items"] = schema }, + ["required"] = new JArray { "items" }, + ["additionalProperties"] = false, + }; + return (wrapped, new SchemaWrapperInfo { IsWrapped = true, WrapperType = "array", PropertyName = "items", ProviderName = this.ProviderName }); + } + + if (schemaType == "string" || schemaType == "number" || schemaType == "integer" || schemaType == "boolean") + { + var wrapped = new JObject + { + ["type"] = "object", + ["properties"] = new JObject { ["value"] = schema }, + ["required"] = new JArray { "value" }, + ["additionalProperties"] = false, + }; + return (wrapped, new SchemaWrapperInfo { IsWrapped = true, WrapperType = schemaType ?? "primitive", PropertyName = "value", ProviderName = this.ProviderName }); + } + + var generic = new JObject + { + ["type"] = "object", + ["properties"] = new JObject { ["data"] = schema }, + ["required"] = new JArray { "data" }, + ["additionalProperties"] = false, + }; + return (generic, new SchemaWrapperInfo { IsWrapped = true, WrapperType = "unknown", PropertyName = "data", ProviderName = this.ProviderName }); + } + + /// + public string Unwrap(string content, SchemaWrapperInfo info) + { + // Default service logic handles unwrap for object-root payloads + return content; + } + } +} diff --git a/src/SmartHopper.Providers.Ollama/OllamaProvider.Streaming.cs b/src/SmartHopper.Providers.Ollama/OllamaProvider.Streaming.cs new file mode 100644 index 000000000..2940b78dc --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/OllamaProvider.Streaming.cs @@ -0,0 +1,405 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.Core; +using SmartHopper.Infrastructure.AICall.Core.Base; +using SmartHopper.Infrastructure.AICall.Core.Interactions; +using SmartHopper.Infrastructure.AICall.Core.Requests; +using SmartHopper.Infrastructure.AICall.Core.Returns; +using SmartHopper.Infrastructure.AICall.Metrics; +using SmartHopper.Infrastructure.AIProviders; +using SmartHopper.Infrastructure.Streaming; + +namespace SmartHopper.Providers.Ollama +{ + /// + /// Streaming half of . + /// + public sealed partial class OllamaProvider + { + /// + protected override IStreamingAdapter CreateStreamingAdapter() + { + return new OllamaStreamingAdapter(this); + } + + /// + /// Provider-scoped streaming adapter for Ollama Chat Completions SSE. + /// Ollama follows the OpenAI streaming protocol (data: {...}\n\n + [DONE]). + /// + private sealed class OllamaStreamingAdapter : AIProviderStreamingAdapter, IStreamingAdapter + { + public OllamaStreamingAdapter(OllamaProvider provider) + : base(provider) + { + } + + public async IAsyncEnumerable StreamAsync( + AIRequestCall request, + StreamingOptions options, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (request == null) + { + yield break; + } + + request = this.Prepare(request); + + if (!string.Equals(request.Endpoint, "/chat/completions", StringComparison.Ordinal)) + { + var unsupported = new AIReturn(); + unsupported.CreateProviderError("Streaming is only supported for /chat/completions in this adapter.", request); + yield return unsupported; + yield break; + } + + JObject body; + try + { + body = JObject.Parse(this.Provider.Encode(request)); + } + catch + { + body = new JObject(); + } + + body["stream"] = true; + + var fullUrl = this.BuildFullUrl(request.Endpoint); + + using var httpClient = this.CreateHttpClient(); + AIReturn? authError = null; + try + { + var apiKey = ((OllamaProvider)this.Provider).GetApiKey(); + this.ApplyAuthentication(httpClient, request.Authentication, apiKey); + } + catch (Exception ex) + { + authError = new AIReturn(); + authError.CreateProviderError(ex.Message, request); + } + + if (authError != null) + { + yield return authError; + yield break; + } + + using var httpRequest = this.CreateSsePost(fullUrl, body.ToString(), "application/json"); + + HttpResponseMessage response; + AIReturn? sendError = null; + try + { + response = await this.SendForStreamAsync(httpClient, httpRequest, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + sendError = new AIReturn(); + sendError.CreateNetworkError(ex.InnerException?.Message ?? ex.Message, request); + response = null!; + } + + if (sendError != null) + { + yield return sendError; + yield break; + } + + if (!response.IsSuccessStatusCode) + { + var content = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false); + var (message, isNetworkLike) = AIProvider.ClassifyHttpError((int)response.StatusCode, response.ReasonPhrase, content, this.Provider.Name); + var err = new AIReturn(); + if (isNetworkLike) + { + err.CreateNetworkError(message, request); + } + else + { + err.CreateProviderError(message, request); + } + + yield return err; + yield break; + } + + var buffer = new StringBuilder(); + var lastEmit = DateTime.UtcNow; + var firstChunk = true; + string finalFinishReason = string.Empty; + int promptTokens = 0; + int completionTokens = 0; + + var assistantAggregate = new AIInteractionText + { + Agent = AIAgent.Assistant, + Content = string.Empty, + Metrics = new AIMetrics { Provider = this.Provider.Name, Model = request.Model }, + }; + + // Tool call accumulation (index -> partial) + var toolCalls = new Dictionary(); + + async IAsyncEnumerable EmitAsync(string text, bool streamingStatus) + { + if (string.IsNullOrEmpty(text)) yield break; + + assistantAggregate.AppendDelta(contentDelta: text); + + var snapshot = new AIInteractionText + { + Agent = assistantAggregate.Agent, + Content = assistantAggregate.Content, + Metrics = new AIMetrics + { + Provider = assistantAggregate.Metrics.Provider, + Model = assistantAggregate.Metrics.Model, + FinishReason = assistantAggregate.Metrics.FinishReason, + InputTokensCached = assistantAggregate.Metrics.InputTokensCached, + InputTokensPrompt = assistantAggregate.Metrics.InputTokensPrompt, + OutputTokensReasoning = assistantAggregate.Metrics.OutputTokensReasoning, + OutputTokensGeneration = assistantAggregate.Metrics.OutputTokensGeneration, + CompletionTime = assistantAggregate.Metrics.CompletionTime, + }, + }; + + var delta = new AIReturn + { + Request = request, + Status = streamingStatus ? AICallStatus.Streaming : AICallStatus.Processing, + }; + delta.SetBody(new List { snapshot }); + yield return delta; + await Task.Yield(); + } + + async Task> FlushAsync(bool force) + { + var results = new List(); + if (buffer.Length == 0) return results; + var elapsed = (DateTime.UtcNow - lastEmit).TotalMilliseconds; + if (force || !options.CoalesceTokens || buffer.Length >= options.PreferredChunkSize || elapsed >= options.CoalesceDelayMs) + { + var text = buffer.ToString(); + buffer.Clear(); + lastEmit = DateTime.UtcNow; + await foreach (var d in EmitAsync(text, streamingStatus: true).WithCancellation(cancellationToken)) + { + results.Add(d); + } + } + + return results; + } + + { + var initial = new AIReturn { Request = request, Status = AICallStatus.Processing }; + initial.SetBody(new List()); + yield return initial; + } + + var idleTimeout = TimeSpan.FromSeconds((double)(request.TimeoutSeconds > 0 ? request.TimeoutSeconds : TimeoutDefaults.DefaultTimeoutSeconds)); + await foreach (var data in this.ReadSseDataAsync( + response, + idleTimeout, + null, + cancellationToken).WithCancellation(cancellationToken)) + { + JObject parsed; + try + { + parsed = JObject.Parse(data); + } + catch + { + continue; + } + + var choices = parsed["choices"] as JArray; + var choice = choices?.FirstOrDefault() as JObject; + var delta = choice?["delta"] as JObject; + var finishReason = choice?["finish_reason"]?.ToString(); + bool hasFinish = !string.IsNullOrEmpty(finishReason); + if (hasFinish) finalFinishReason = finishReason; + + var usage = parsed["usage"] as JObject; + if (usage != null) + { + var pt = usage["prompt_tokens"]?.Value(); + var ct = usage["completion_tokens"]?.Value(); + if (pt.HasValue) promptTokens = pt.Value; + if (ct.HasValue) completionTokens = ct.Value; + + assistantAggregate.AppendDelta(metricsDelta: new AIMetrics + { + Provider = this.Provider.Name, + Model = request.Model, + InputTokensPrompt = pt ?? 0, + OutputTokensGeneration = ct ?? 0, + }); + } + + var contentDelta = delta?["content"]?.ToString(); + if (!string.IsNullOrEmpty(contentDelta)) + { + buffer.Append(contentDelta); + var emitted = await FlushAsync(force: firstChunk).ConfigureAwait(false); + if (firstChunk) firstChunk = false; + foreach (var d in emitted) + { + yield return d; + } + } + + var tcArray = delta?["tool_calls"] as JArray; + if (tcArray != null) + { + foreach (var t in tcArray.OfType()) + { + var idx = t["index"]?.Value() ?? 0; + if (!toolCalls.TryGetValue(idx, out var entry)) + { + entry = (Id: string.Empty, Name: string.Empty, Args: new StringBuilder()); + toolCalls[idx] = entry; + } + + var idVal = t["id"]?.ToString(); + if (!string.IsNullOrEmpty(idVal)) entry.Id = idVal; + + var func = t["function"] as JObject; + if (func != null) + { + var name = func["name"]?.ToString(); + if (!string.IsNullOrEmpty(name)) entry.Name = name; + var args = func["arguments"]?.ToString(); + if (!string.IsNullOrEmpty(args)) entry.Args.Append(args); + } + + toolCalls[idx] = entry; + } + + var emittedTc = await FlushAsync(force: true).ConfigureAwait(false); + foreach (var d in emittedTc) + { + yield return d; + } + + var interactions = new List(); + foreach (var kv in toolCalls.OrderBy(k => k.Key)) + { + var (id, name, argsSb) = kv.Value; + JObject? argsObj = null; + var argsStr = argsSb.ToString(); + try + { + if (!string.IsNullOrWhiteSpace(argsStr)) + { + argsObj = JObject.Parse(argsStr); + } + } + catch + { + // Partial JSON, ignore + } + + interactions.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj }); + } + + var tcDelta = new AIReturn { Request = request, Status = AICallStatus.CallingTools }; + tcDelta.SetBody(interactions); + yield return tcDelta; + } + } + + var finalEmitted = await FlushAsync(force: true).ConfigureAwait(false); + foreach (var d in finalEmitted) + { + yield return d; + } + + var final = new AIReturn + { + Request = request, + Status = AICallStatus.Finished, + }; + + assistantAggregate.AppendDelta(metricsDelta: new AIMetrics + { + FinishReason = string.IsNullOrEmpty(finalFinishReason) ? "stop" : finalFinishReason, + }); + + var finalBuilder = AIBodyBuilder.Create(); + if (!string.IsNullOrEmpty(assistantAggregate.Content)) + { + var finalSnapshot = new AIInteractionText + { + Agent = assistantAggregate.Agent, + Content = assistantAggregate.Content, + Metrics = new AIMetrics + { + Provider = assistantAggregate.Metrics.Provider, + Model = assistantAggregate.Metrics.Model, + FinishReason = assistantAggregate.Metrics.FinishReason, + InputTokensCached = assistantAggregate.Metrics.InputTokensCached, + InputTokensPrompt = assistantAggregate.Metrics.InputTokensPrompt, + OutputTokensReasoning = assistantAggregate.Metrics.OutputTokensReasoning, + OutputTokensGeneration = assistantAggregate.Metrics.OutputTokensGeneration, + CompletionTime = assistantAggregate.Metrics.CompletionTime, + }, + }; + finalBuilder.Add(finalSnapshot, markAsNew: false); + } + + foreach (var kv in toolCalls.OrderBy(k => k.Key)) + { + var (id, name, argsSb) = kv.Value; + JObject? argsObj = null; + var argsStr = argsSb.ToString(); + try + { + if (!string.IsNullOrWhiteSpace(argsStr)) + { + argsObj = JObject.Parse(argsStr); + } + } + catch + { + // Partial JSON + } + + finalBuilder.Add(new AIInteractionToolCall { Id = id, Name = name, Arguments = argsObj }, markAsNew: false); + } + + final.SetBody(finalBuilder.Build()); + yield return final; + } + } + } +} diff --git a/src/SmartHopper.Providers.Ollama/OllamaProvider.cs b/src/SmartHopper.Providers.Ollama/OllamaProvider.cs new file mode 100644 index 000000000..7215778eb --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/OllamaProvider.cs @@ -0,0 +1,571 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Drawing; +using System.IO; +using System.Linq; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using SmartHopper.Infrastructure.AICall.Core; +using SmartHopper.Infrastructure.AICall.Core.Base; +using SmartHopper.Infrastructure.AICall.Core.Interactions; +using SmartHopper.Infrastructure.AICall.Core.Requests; +using SmartHopper.Infrastructure.AICall.JsonSchemas; +using SmartHopper.Infrastructure.AICall.Metrics; +using SmartHopper.Infrastructure.AIProviders; +using SmartHopper.Infrastructure.Diagnostics; +using SmartHopper.Infrastructure.Streaming; + +namespace SmartHopper.Providers.Ollama +{ + /// + /// Ollama provider implementation. + /// Targets local Ollama instances (https://ollama.com/) through the + /// OpenAI-compatible /v1/chat/completions endpoint. + /// + public sealed partial class OllamaProvider : AIProvider + { + /// + /// The name of the provider. Used by the UI for provider selection. + /// + public static readonly string NameValue = "Ollama"; + + /// + /// Fallback default server URL when no Base URL has been configured. + /// Ollama defaults to port 11434 on localhost. + /// + public static readonly Uri FallbackServerUrl = new ("http://localhost:11434/v1"); + + /// + /// Initializes a new instance of the class. + /// Private constructor to enforce singleton pattern. + /// + private OllamaProvider() + { + this.Models = new OllamaProviderModels(this); + + // Register provider-specific JSON schema adapter (object-root wrapper, OpenAI-style) + JsonSchemaAdapterRegistry.Register(new OllamaJsonSchemaAdapter()); + } + + /// + public override string Name => NameValue; + + /// + /// Gets the server URL configured for this provider. + /// Reads the user-provided ServerUrl setting and falls back to + /// when the setting is empty or not a valid absolute HTTP(S) URL. + /// + public override Uri DefaultServerUrl + { + get + { + var configured = this.GetServerUrlSetting(); + if (!string.IsNullOrWhiteSpace(configured) + && Uri.TryCreate(configured, UriKind.Absolute, out var uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + return uri; + } + + return FallbackServerUrl; + } + } + + /// + public override bool IsEnabled => true; + + /// + /// Helper to retrieve the configured API key for this provider. + /// Exposed to the nested streaming adapter to avoid protected access issues. + /// + internal string GetApiKey() + { + return this.GetSetting("ApiKey"); + } + + /// + /// Helper to retrieve the configured Base URL setting (raw string). + /// + internal string GetServerUrlSetting() + { + return this.GetSetting("ServerUrl"); + } + + /// + public override Image Icon + { + get + { + var iconBytes = Properties.Resources.ollama_icon; + using (var ms = new MemoryStream(iconBytes)) + { + return new Bitmap(ms); + } + } + } + + /// + public override AIRequestCall PreCall(AIRequestCall request) + { + // First do the base PreCall + request = base.PreCall(request); + + // Setup HTTP method, content type, and authentication. + request.HttpMethod = "POST"; + request.ContentType = "application/json"; + + // Ollama does not require an API key by default; the OpenAI-compatible + // endpoint accepts an arbitrary placeholder. Use bearer auth only when + // the user has explicitly configured one (e.g., a reverse-proxy in front). + var apiKey = this.GetApiKey(); + request.Authentication = string.IsNullOrWhiteSpace(apiKey) ? "none" : "bearer"; + + // Ollama exposes the OpenAI-compatible chat completions endpoint + request.Endpoint = "/chat/completions"; + + return request; + } + + /// + public override string Encode(AIRequestCall request) + { + if (request.HttpMethod == "GET" || request.HttpMethod == "DELETE") + { + return "GET and DELETE requests do not use a request body"; + } + + var p = request.Parameters; + + int maxTokens = p?.MaxTokens ?? this.GetSetting("MaxTokens"); + double temperature = p?.Temperature ?? this.GetSetting("Temperature"); + string? toolFilter = request.Body.ToolFilter; + + Debug.WriteLine($"[Ollama] Encode - Model: {request.Model}, MaxTokens: {maxTokens}"); + + // Simple sequential encoding (same approach as OpenAI/MistralAI/DeepSeek providers). + var convertedMessages = new JArray(); + + // Merge System and Summary interactions before encoding + var mergedInteractions = this.MergeSystemAndSummary(request.Body.Interactions); + + foreach (var interaction in mergedInteractions) + { + try + { + var token = this.EncodeToJToken(interaction); + if (token == null) + { + continue; + } + + var role = token["role"]?.ToString(); + if (string.IsNullOrEmpty(role)) + { + continue; + } + + convertedMessages.Add(token); + } + catch (Exception ex) + { + Debug.WriteLine($"[Ollama] Warning: Could not encode interaction: {ex.Message}"); + } + } + + // Build request body + var requestBody = new JObject + { + ["model"] = request.Model, + ["messages"] = convertedMessages, + ["max_tokens"] = maxTokens, + ["temperature"] = temperature, + }; + + // Apply optional parameters from extras only + if (p?.Extras != null) + { + if (p.Extras.TryGetValue("top_p", out var topPToken) && topPToken != null) + { + requestBody["top_p"] = topPToken.Value(); + } + + if (p.Extras.TryGetValue("top_k", out var topKToken) && topKToken != null) + { + requestBody["top_k"] = topKToken.Value(); + } + + if (p.Extras.TryGetValue("seed", out var seedToken) && seedToken != null) + { + requestBody["seed"] = seedToken.Value(); + } + + if (p.Extras.TryGetValue("presence_penalty", out var ppToken) && ppToken != null) + { + requestBody["presence_penalty"] = ppToken; + } + + if (p.Extras.TryGetValue("frequency_penalty", out var fpToken) && fpToken != null) + { + requestBody["frequency_penalty"] = fpToken; + } + } + + // Add JSON response format if schema is provided (centralized wrapping) + if (!string.IsNullOrWhiteSpace(request.Body.JsonOutputSchema)) + { + try + { + var svc = JsonSchemaService.Instance; + var schemaObj = JObject.Parse(request.Body.JsonOutputSchema); + var (wrappedSchema, wrapperInfo) = svc.WrapForProvider(schemaObj, this.Name); + svc.SetCurrentWrapperInfo(wrapperInfo); + Debug.WriteLine($"[Ollama] Schema wrapper info stored (central): IsWrapped={wrapperInfo.IsWrapped}, Type={wrapperInfo.WrapperType}, Property={wrapperInfo.PropertyName}"); + + // Ollama supports json_object response_format on its OpenAI-compatible endpoint + requestBody["response_format"] = new JObject { ["type"] = "json_object" }; + + // Add a system guidance message including the wrapped schema + var systemMessage = new JObject + { + ["role"] = "system", + ["content"] = "The response must be a valid JSON object that strictly follows this schema: " + wrappedSchema.ToString(Newtonsoft.Json.Formatting.None), + }; + convertedMessages.Insert(0, systemMessage); + } + catch (Exception ex) + { + Debug.WriteLine($"[Ollama] Failed to parse/handle JSON schema: {ex.Message}"); + JsonSchemaService.Instance.SetCurrentWrapperInfo(new SchemaWrapperInfo { IsWrapped = false }); + requestBody["response_format"] = new JObject { ["type"] = "text" }; + } + } + else + { + JsonSchemaService.Instance.SetCurrentWrapperInfo(new SchemaWrapperInfo { IsWrapped = false }); + requestBody["response_format"] = new JObject { ["type"] = "text" }; + } + + // Add tools if requested + if (!string.IsNullOrWhiteSpace(toolFilter)) + { + var tools = this.GetFormattedTools(toolFilter); + if (tools != null && tools.Count > 0) + { + requestBody["tools"] = tools; + + // Forced tool call uses OpenAI-compatible tool_choice with function name + if (request.ForceToolCall && !string.IsNullOrWhiteSpace(request.ForceToolName)) + { + requestBody["tool_choice"] = new JObject + { + ["type"] = "function", + ["function"] = new JObject { ["name"] = request.ForceToolName, }, + }; + Debug.WriteLine($"[Ollama] Forcing tool call: {request.ForceToolName}"); + } + else + { + requestBody["tool_choice"] = "auto"; + } + } + } + + return requestBody.ToString(); + } + + /// + public override string Encode(IAIInteraction interaction) + { + try + { + var token = this.EncodeToJToken(interaction); + return token?.ToString() ?? string.Empty; + } + catch (Exception ex) + { + Debug.WriteLine($"[Ollama] Encode(IAIInteraction) error: {ex.Message}"); + return string.Empty; + } + } + + /// + /// Converts a single interaction into a Ollama/OpenAI-compatible message object. + /// Returns null for interactions that should not be sent (e.g., UI-only diagnostics). + /// + private JToken? EncodeToJToken(IAIInteraction interaction) + { + if (interaction == null) + { + return null; + } + + // UI-only diagnostics must not be sent to providers + if (interaction is AIInteractionRuntimeMessage) + { + return null; + } + + // Map role + string role; + switch (interaction.Agent) + { + case AIAgent.System: + case AIAgent.Context: + role = "system"; + break; + case AIAgent.User: + role = "user"; + break; + case AIAgent.Assistant: + case AIAgent.ToolCall: + role = "assistant"; + break; + case AIAgent.ToolResult: + role = "tool"; + break; + default: + return null; + } + + var messageObj = new JObject { ["role"] = role }; + + if (interaction is AIInteractionText textInteraction) + { + messageObj["content"] = textInteraction.Content ?? string.Empty; + } + else if (interaction is AIInteractionToolResult toolResultInteraction) + { + messageObj["content"] = toolResultInteraction.Result != null + ? JsonConvert.SerializeObject(toolResultInteraction.Result, Formatting.None) + : string.Empty; + + if (!string.IsNullOrWhiteSpace(toolResultInteraction.Id)) + { + messageObj["tool_call_id"] = toolResultInteraction.Id; + } + + if (!string.IsNullOrWhiteSpace(toolResultInteraction.Name)) + { + messageObj["name"] = toolResultInteraction.Name; + } + } + else if (interaction is AIInteractionToolCall toolCallInteraction) + { + var toolCallObj = new JObject + { + ["id"] = toolCallInteraction.Id ?? string.Empty, + ["type"] = "function", + ["function"] = new JObject + { + ["name"] = toolCallInteraction.Name ?? string.Empty, + ["arguments"] = toolCallInteraction.Arguments?.ToString() ?? "{}", + }, + }; + messageObj["tool_calls"] = new JArray { toolCallObj }; + messageObj["content"] = string.Empty; + } + else if (interaction is AIInteractionImage imageInteraction) + { + // Ollama vision support depends on the loaded model; fall back to the original prompt + messageObj["content"] = imageInteraction.OriginalPrompt ?? string.Empty; + } + else + { + messageObj["content"] = string.Empty; + } + + return messageObj; + } + + /// + public override List Decode(JObject response) + { + var interactions = new List(); + if (response == null) + { + return interactions; + } + + try + { + // OpenAI-compatible error envelope: {"error": {"message": "...", "type": "..."}} + if (response["error"] is JObject errorObj) + { + var errMsg = errorObj["message"]?.ToString() + ?? errorObj["type"]?.ToString() + ?? "Provider returned an error"; + Debug.WriteLine($"[Ollama] Decode: provider error in response body: {errMsg}"); + interactions.Add(new AIInteractionRuntimeMessage { Severity = SHRuntimeMessageSeverity.Error, Content = errMsg }); + return interactions; + } + + var choices = response["choices"] as JArray; + var firstChoice = choices?.FirstOrDefault() as JObject; + var message = firstChoice?["message"] as JObject; + if (message == null) + { + Debug.WriteLine("[Ollama] Decode: No message found in response"); + return interactions; + } + + // Robust content extraction: handle string, array of parts, or object content + string content; + var contentToken = message["content"]; + if (contentToken is JArray contentParts && contentParts.Count > 0) + { + var texts = new List(); + foreach (var part in contentParts) + { + var text = part?["text"]?.ToString() + ?? part?["content"]?.ToString() + ?? part?.ToString(Newtonsoft.Json.Formatting.None) + ?? string.Empty; + if (!string.IsNullOrWhiteSpace(text)) + { + texts.Add(text); + } + } + + content = string.Join("\n", texts); + } + else + { + content = contentToken?.ToString() ?? string.Empty; + } + + // Apply centralized unwrapping using stored wrapper info + var wrapperInfo = JsonSchemaService.Instance.GetCurrentWrapperInfo(); + if (wrapperInfo != null && wrapperInfo.IsWrapped) + { + Debug.WriteLine($"[Ollama] Unwrapping response content using wrapper info (central): Type={wrapperInfo.WrapperType}, Property={wrapperInfo.PropertyName}"); + content = JsonSchemaService.Instance.Unwrap(content, wrapperInfo); + } + + // Some Ollama builds surface reasoning text via reasoning_content (e.g. when running thinking models) + var reasoning = message["reasoning_content"]?.ToString(); + + var interaction = new AIInteractionText(); + interaction.SetResult( + agent: AIAgent.Assistant, + content: content, + reasoning: string.IsNullOrWhiteSpace(reasoning) ? null : reasoning); + interaction.Metrics = this.DecodeMetrics(response); + interactions.Add(interaction); + + // Add an AIInteractionToolCall for each tool call + if (message["tool_calls"] is JArray tcs && tcs.Count > 0) + { + foreach (JObject tc in tcs.OfType()) + { + var function = tc["function"] as JObject; + var argumentsStr = function?["arguments"]?.ToString() ?? "{}"; + JObject? argumentsObj = null; + try + { + if (!string.IsNullOrWhiteSpace(argumentsStr)) + { + argumentsObj = JObject.Parse(argumentsStr); + } + } + catch + { + // Some local backends emit non-JSON arguments; pass them through as-is + } + + var toolCall = new AIInteractionToolCall + { + Id = tc["id"]?.ToString(), + Name = function?["name"]?.ToString(), + Arguments = argumentsObj, + Reasoning = string.IsNullOrWhiteSpace(reasoning) ? null : reasoning, + }; + interactions.Add(toolCall); + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[Ollama] Decode error: {ex.Message}"); + } + + return interactions; + } + + /// + /// Decodes the metrics from the response (token usage + finish reason). + /// + private AIMetrics DecodeMetrics(JObject response) + { + var choices = response["choices"] as JArray; + var firstChoice = choices?.FirstOrDefault() as JObject; + var usage = response["usage"] as JObject; + + var metrics = new AIMetrics + { + Provider = this.Name, + FinishReason = firstChoice?["finish_reason"]?.ToString() ?? string.Empty, + InputTokensPrompt = usage?["prompt_tokens"]?.Value() ?? 0, + OutputTokensGeneration = usage?["completion_tokens"]?.Value() ?? 0, + }; + + return metrics; + } + + /// + public override IEnumerable GetExtraDescriptors() + { + return new[] + { + new AIExtraDescriptor( + "top_p", + "Top P", + "Nucleus sampling parameter (0.0–1.0). Lower values make output more focused; higher values more diverse. Leave empty to use the model default.", + typeof(double), + null), + new AIExtraDescriptor( + "top_k", + "Top K", + "Limits the next-token candidate pool to the K most likely tokens. Leave empty to use the model default.", + typeof(int), + null), + new AIExtraDescriptor( + "seed", + "Seed", + "Reproducibility seed for deterministic sampling. Use the same seed to get similar outputs. Leave empty for random.", + typeof(int), + null), + new AIExtraDescriptor( + "presence_penalty", + "Presence Penalty", + "Penalizes tokens already present in the text (-2.0 to 2.0). Positive values encourage new topics.", + typeof(double), + null), + new AIExtraDescriptor( + "frequency_penalty", + "Frequency Penalty", + "Penalizes frequent tokens (-2.0 to 2.0). Positive values reduce repetition.", + typeof(double), + null), + }; + } + } +} diff --git a/src/SmartHopper.Providers.Ollama/OllamaProviderFactory.cs b/src/SmartHopper.Providers.Ollama/OllamaProviderFactory.cs new file mode 100644 index 000000000..a5341ee91 --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/OllamaProviderFactory.cs @@ -0,0 +1,41 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using SmartHopper.Infrastructure.AIProviders; + +namespace SmartHopper.Providers.Ollama +{ + /// + /// Factory class for creating instances of the Ollama provider and its settings. + /// This class is discovered by the ProviderManager through reflection. + /// + public class OllamaProviderFactory : IAIProviderFactory + { + /// + public IAIProvider CreateProvider() + { + return OllamaProvider.Instance; + } + + /// + public IAIProviderSettings CreateProviderSettings() + { + return new OllamaProviderSettings(OllamaProvider.Instance); + } + } +} diff --git a/src/SmartHopper.Providers.Ollama/OllamaProviderModels.cs b/src/SmartHopper.Providers.Ollama/OllamaProviderModels.cs new file mode 100644 index 000000000..ae353d5be --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/OllamaProviderModels.cs @@ -0,0 +1,54 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System.Collections.Generic; +using System.Threading.Tasks; +using SmartHopper.Infrastructure.AIModels; +using SmartHopper.Infrastructure.AIProviders; + +namespace SmartHopper.Providers.Ollama +{ + /// + /// Ollama provider-specific model management implementation. + /// + /// + /// Ollama is run locally and the available models depend entirely on what the + /// user has pulled with ollama pull. We therefore do not ship a static catalog + /// of curated models. The user supplies the model name (e.g. llama3.1, + /// qwen2.5:14b) through the provider's Model setting; we return an empty list + /// and rely on the user-supplied name. + /// + public class OllamaProviderModels : AIProviderModels + { + /// + /// Initializes a new instance of the class. + /// + /// The Ollama provider instance. + public OllamaProviderModels(OllamaProvider provider) + : base(provider) + { + } + + /// + public override Task> RetrieveModels() + { + // Models are user-managed in Ollama (pulled with `ollama pull`); no static catalog. + return Task.FromResult(new List()); + } + } +} diff --git a/src/SmartHopper.Providers.Ollama/OllamaProviderSettings.cs b/src/SmartHopper.Providers.Ollama/OllamaProviderSettings.cs new file mode 100644 index 000000000..f9882452a --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/OllamaProviderSettings.cs @@ -0,0 +1,175 @@ +/* + * SmartHopper - AI-powered Grasshopper Plugin + * Copyright (C) 2024-2026 Marc Roca Musach + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this library; if not, see . + */ + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using SmartHopper.Infrastructure.AIProviders; +using SmartHopper.Infrastructure.Dialogs; +using SmartHopper.Infrastructure.Settings; + +namespace SmartHopper.Providers.Ollama +{ + /// + /// Settings implementation for the Ollama provider. + /// Exposes a configurable Base URL pointing at a local Ollama instance, + /// plus an optional API key for installations placed behind an authenticating proxy. + /// + public class OllamaProviderSettings : AIProviderSettings + { + private new readonly IAIProvider provider; + + /// + /// Initializes a new instance of the class. + /// + /// The provider associated with these settings. + public OllamaProviderSettings(IAIProvider provider) + : base(provider) + { + this.provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + public override IEnumerable GetSettingDescriptors() + { + return new[] + { + new SettingDescriptor + { + Name = "ServerUrl", + DisplayName = "Base URL", + Description = "Base URL of your Ollama server, including the OpenAI-compatible API prefix (e.g. http://localhost:11434/v1).", + Type = typeof(string), + DefaultValue = OllamaProvider.FallbackServerUrl.ToString(), + }, + new SettingDescriptor + { + Name = "ApiKey", + DisplayName = "API Key", + Description = "Optional API key. Ollama itself does not require authentication, but you can set one if your instance is behind a reverse proxy that enforces it.", + IsSecret = true, + Type = typeof(string), + }, + new SettingDescriptor + { + Name = "Model", + DisplayName = "Model", + Description = "Model tag as pulled with `ollama pull` (e.g. 'llama3.1', 'qwen2.5:14b', 'mistral:7b-instruct').", + Type = typeof(string), + }.Apply(d => d.SetLazyDefault(() => this.provider.GetDefaultModel())), + new SettingDescriptor + { + Name = "EnableStreaming", + Type = typeof(bool), + DefaultValue = true, + DisplayName = "Enable Streaming", + Description = "Allow streaming responses for this provider. When enabled, you will receive the response as it is generated.", + }, + new SettingDescriptor + { + Name = "MaxTokens", + DisplayName = "Max Tokens", + Description = "Maximum number of tokens to generate.", + Type = typeof(int), + DefaultValue = 2000, + ControlParams = new NumericSettingDescriptorControl + { + UseSlider = false, + Min = 1, + Max = 32768, + Step = 1, + }, + }, + new SettingDescriptor + { + Name = "Temperature", + Type = typeof(string), + DefaultValue = "0.5", + DisplayName = "Temperature", + Description = "Controls randomness (0.0–2.0). Higher values produce more diverse output; lower values are more focused and deterministic.", + }, + }; + } + + /// + public override bool ValidateSettings(Dictionary settings) + { + Debug.WriteLine($"[Ollama] ValidateSettings called. Settings null? {settings == null}"); + + if (settings == null) + { + return false; + } + + const bool showErrorDialogs = true; + + // Validate Base URL when supplied: must parse as an absolute HTTP(S) URI. + if (settings.TryGetValue("ServerUrl", out var serverUrlObj) && serverUrlObj != null) + { + var serverUrl = serverUrlObj.ToString(); + if (!string.IsNullOrWhiteSpace(serverUrl)) + { + if (!Uri.TryCreate(serverUrl, UriKind.Absolute, out var parsed) + || (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps)) + { + if (showErrorDialogs) + { + StyledMessageDialog.ShowError( + "Base URL must be an absolute HTTP or HTTPS URL (for example http://localhost:11434/v1).", + "Validation Error"); + } + + return false; + } + } + } + + // MaxTokens must be a positive integer + if (settings.TryGetValue("MaxTokens", out var maxTokensObj) && maxTokensObj != null) + { + if (!int.TryParse(maxTokensObj.ToString(), out var parsedMaxTokens) || parsedMaxTokens <= 0) + { + if (showErrorDialogs) + { + StyledMessageDialog.ShowError("Max tokens must be a positive number.", "Validation Error"); + } + + return false; + } + } + + // Temperature must be in [0.0, 2.0] + if (settings.TryGetValue("Temperature", out var temperatureObj) && temperatureObj != null) + { + if (!double.TryParse(temperatureObj.ToString(), out var parsedTemperature) + || parsedTemperature < 0.0 + || parsedTemperature > 2.0) + { + if (showErrorDialogs) + { + StyledMessageDialog.ShowError("Temperature must be between 0.0 and 2.0.", "Validation Error"); + } + + return false; + } + } + + return true; + } + } +} diff --git a/src/SmartHopper.Providers.Ollama/Properties/Resources.Designer.cs b/src/SmartHopper.Providers.Ollama/Properties/Resources.Designer.cs new file mode 100644 index 000000000..80c12ad09 --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/Properties/Resources.Designer.cs @@ -0,0 +1,75 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.42000 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace SmartHopper.Providers.Ollama.Properties { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("SmartHopper.Providers.Ollama.Properties.Resources", typeof(Resources).Assembly); + resourceMan = temp; + } + + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized resource of type System.Byte[]. + /// + internal static byte[] ollama_icon { + get { + object obj = ResourceManager.GetObject("ollama_icon", resourceCulture); + return ((byte[])(obj)); + } + } + } +} diff --git a/src/SmartHopper.Providers.Ollama/Properties/Resources.resx b/src/SmartHopper.Providers.Ollama/Properties/Resources.resx new file mode 100644 index 000000000..6110ebd91 --- /dev/null +++ b/src/SmartHopper.Providers.Ollama/Properties/Resources.resx @@ -0,0 +1,124 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + + ..\Resources\ollama_icon.png;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + diff --git a/src/SmartHopper.Providers.Ollama/Resources/ollama_icon.png b/src/SmartHopper.Providers.Ollama/Resources/ollama_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..cd3e81224a7fd62b2d9ced4a4a90888bdff42a07 GIT binary patch literal 137 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`0iG_7Ar*6y6Be*vxNzae`h^QW z%FjJ}00>-GIlf3zku3Q8tCYuZ63>MzSC*V%$Z61E&bzbYU`O})rgak04uNl)0+};% k7{VInB^GoB9%W@. +--> + + + + net7.0-windows;net7.0 + true + NU1701;NETSDK1086;SA1124;SA1200 + true + true + + + + SmartHopper Ollama Provider + Ollama provider for SmartHopper. Connects to a local Ollama instance via its OpenAI-compatible API. + + + + + $(SolutionDir)bin/$(SolutionVersion)/$(Configuration) + + + + + + + + + true + $(DefineConstants);WINDOWS + + + + + + + + + + + + + + + + + + + + + + True + True + Resources.resx + + + + + ResXFileCodeGenerator + Resources.Designer.cs + + + diff --git a/yak-package/manifest.yml b/yak-package/manifest.yml index 3920e97ce..80d77366b 100644 --- a/yak-package/manifest.yml +++ b/yak-package/manifest.yml @@ -40,4 +40,6 @@ keywords: - deepseek - anthropic - open-router + - localai + - ollama icon: icon.png \ No newline at end of file From c14f0aeac93ea8394dab7154be3917e05aa3efaa Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 13 May 2026 22:13:31 +0000 Subject: [PATCH 2/2] chore(ci): update license headers --- src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs | 2 +- src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs | 2 +- src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs | 2 +- src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs | 2 +- src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs | 2 +- src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs | 2 +- .../SmartHopper.Providers.LocalAI.csproj | 2 +- src/SmartHopper.Providers.Ollama/OllamaJsonSchemaAdapter.cs | 2 +- src/SmartHopper.Providers.Ollama/OllamaProvider.Streaming.cs | 2 +- src/SmartHopper.Providers.Ollama/OllamaProvider.cs | 2 +- src/SmartHopper.Providers.Ollama/OllamaProviderFactory.cs | 2 +- src/SmartHopper.Providers.Ollama/OllamaProviderModels.cs | 2 +- src/SmartHopper.Providers.Ollama/OllamaProviderSettings.cs | 2 +- .../SmartHopper.Providers.Ollama.csproj | 2 +- 14 files changed, 14 insertions(+), 14 deletions(-) diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs b/src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs index 446a52974..39ebe769e 100644 --- a/src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs +++ b/src/SmartHopper.Providers.LocalAI/LocalAIJsonSchemaAdapter.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs index 8824cbe54..7d3dd2f6c 100644 --- a/src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.Streaming.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs index de4e72801..34b5043a3 100644 --- a/src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProvider.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs index fcbbea7a0..7ce52575b 100644 --- a/src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProviderFactory.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs index ec75487f7..871568620 100644 --- a/src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProviderModels.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs b/src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs index e7e469b22..ce210a378 100644 --- a/src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs +++ b/src/SmartHopper.Providers.LocalAI/LocalAIProviderSettings.cs @@ -1,4 +1,4 @@ -/* +/* * SmartHopper - AI-powered Grasshopper Plugin * Copyright (C) 2024-2026 Marc Roca Musach * diff --git a/src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj b/src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj index 4fc8b8848..421d0099f 100644 --- a/src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj +++ b/src/SmartHopper.Providers.LocalAI/SmartHopper.Providers.LocalAI.csproj @@ -1,4 +1,4 @@ -