diff --git a/ImageDescriber.Test/OllamaClientTests.cs b/ImageDescriber.Test/OllamaClientTests.cs new file mode 100644 index 0000000..dd21b06 --- /dev/null +++ b/ImageDescriber.Test/OllamaClientTests.cs @@ -0,0 +1,88 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.ImageDescriber.Tests; + +using ktsu.ImageDescriber.Verbs; +using ktsu.Semantics.Strings; + +[TestClass] +public class OllamaClientTests +{ + [TestMethod] + [DataRow("localhost:11434")] + [DataRow("http://localhost:11434")] + [DataRow("http://localhost:11434/")] + [DataRow(" http://localhost:11434// ")] + public void GenerateUriIsUnderTheEndpointWhateverFormItWasTypedIn(string endpoint) + { + Uri uri = OllamaClient.GetGenerateUri(endpoint.As()); + + Assert.AreEqual("http://localhost:11434/api/generate", uri.AbsoluteUri); + } + + [TestMethod] + public void GenerateUriKeepsAnEndpointPathPrefix() + { + Uri uri = OllamaClient.GetGenerateUri("https://example.com/ollama/".As()); + + Assert.AreEqual("https://example.com/ollama/api/generate", uri.AbsoluteUri); + } + + [TestMethod] + [DataRow("localhost:11434", "http://localhost:11434")] + [DataRow("http://localhost:11434/", "http://localhost:11434")] + [DataRow("https://ollama.example.com", "https://ollama.example.com")] + [DataRow("192.168.1.5:11434", "http://192.168.1.5:11434")] + public void NormalizeEndpointAddsAMissingSchemeAndDropsTrailingSlashes(string input, string expected) => + Assert.AreEqual(expected, OllamaClient.NormalizeEndpoint(input)); + + [TestMethod] + [DataRow("")] + [DataRow(" ")] + [DataRow("http://")] + [DataRow("not a url")] + [DataRow("ftp://localhost:11434")] + [DataRow("http://localhost:11434/?x=1")] + public void NormalizeEndpointRejectsAnythingButAnHttpAddress(string input) => + Assert.IsNull(OllamaClient.NormalizeEndpoint(input)); + + [TestMethod] + [DataRow("http://")] + [DataRow("not a url")] + public void IsAvailableReturnsFalseForAnInvalidEndpoint(string endpoint) => + Assert.IsFalse(OllamaClient.IsAvailableAsync(endpoint.As()).GetAwaiter().GetResult()); + + [TestMethod] + public void ConfigureSavesTheNormalizedEndpoint() + { + using StringWriter output = new(); + + OllamaEndpoint chosen = Configure.ChooseEndpoint(" localhost:11434/ ", "http://old:1".As(), output); + + Assert.AreEqual("http://localhost:11434", chosen.WeakString); + Assert.AreEqual(string.Empty, output.ToString()); + } + + [TestMethod] + [DataRow(null)] + [DataRow(" ")] + public void ConfigureKeepsTheCurrentEndpointWhenNothingIsTyped(string? input) + { + using StringWriter output = new(); + + OllamaEndpoint chosen = Configure.ChooseEndpoint(input, "http://old:1".As(), output); + + Assert.AreEqual("http://old:1", chosen.WeakString); + } + + [TestMethod] + public void ConfigureKeepsTheCurrentEndpointAndSaysWhyWhenTheInputIsInvalid() + { + using StringWriter output = new(); + + OllamaEndpoint chosen = Configure.ChooseEndpoint("not a url", "http://old:1".As(), output); + + Assert.AreEqual("http://old:1", chosen.WeakString); + Assert.Contains("\"not a url\" is not an http or https address. Keeping http://old:1.", output.ToString()); + } +} diff --git a/ImageDescriber.Test/ScanTests.cs b/ImageDescriber.Test/ScanTests.cs index af77c35..244b4ff 100644 --- a/ImageDescriber.Test/ScanTests.cs +++ b/ImageDescriber.Test/ScanTests.cs @@ -246,6 +246,29 @@ public void ScanReportsFailedImagesAndCompletes() Assert.Contains("Scan complete.", text); } + [TestMethod] + public void ScanReportsAnInvalidEndpointInsteadOfCrashing() + { + PersistentState originalSettings = Program.Settings; + TextWriter originalOut = Console.Out; + using StringWriter output = new(); + try + { + Program.Settings = new PersistentState(); + Console.SetOut(output); + + Scan scan = new() { PathString = Path.GetTempPath(), EndpointString = "not a url", ModelString = "test-model" }; + scan.Run(scan); + } + finally + { + Console.SetOut(originalOut); + Program.Settings = originalSettings; + } + + Assert.Contains("Error: Invalid Ollama endpoint \"not a url\"", output.ToString()); + } + /// /// Serves /api/generate like Ollama, except that a request mentioning bad.jpg gets an HTML /// page, as a proxy or the wrong service on the endpoint would return. diff --git a/ImageDescriber/OllamaClient.cs b/ImageDescriber/OllamaClient.cs index 2971060..a0de29c 100644 --- a/ImageDescriber/OllamaClient.cs +++ b/ImageDescriber/OllamaClient.cs @@ -18,11 +18,46 @@ internal static class OllamaClient Timeout = TimeSpan.FromMinutes(10), }; + /// + /// Normalizes an endpoint as a user would type it: adds http:// when the scheme is + /// missing (Ollama prints its address as localhost:11434) and drops trailing slashes. + /// Returns unless the result is an absolute http or https URI. + /// + internal static string? NormalizeEndpoint(string? value) + { + string trimmed = value?.Trim() ?? string.Empty; + if (trimmed.Length == 0) + { + return null; + } + + string withScheme = trimmed.Contains(Uri.SchemeDelimiter, StringComparison.Ordinal) ? trimmed : $"{Uri.UriSchemeHttp}{Uri.SchemeDelimiter}{trimmed}"; + if (!Uri.TryCreate(withScheme, UriKind.Absolute, out Uri? uri) + || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps) + || string.IsNullOrEmpty(uri.Host) + || !string.IsNullOrEmpty(uri.Query) + || !string.IsNullOrEmpty(uri.Fragment)) + { + return null; + } + + return uri.GetLeftPart(UriPartial.Path).TrimEnd('/'); + } + + /// + /// Returns the endpoint as a base URI ending in exactly one slash, so relative request paths + /// resolve under it rather than doubling or replacing its last segment. + /// + internal static Uri GetBaseUri(OllamaEndpoint endpoint) => + new($"{NormalizeEndpoint(endpoint.WeakString) ?? throw new ArgumentException($"Invalid Ollama endpoint: {endpoint}", nameof(endpoint))}/"); + + internal static Uri GetGenerateUri(OllamaEndpoint endpoint) => new(GetBaseUri(endpoint), "api/generate"); + internal static async Task IsAvailableAsync(OllamaEndpoint endpoint) { try { - using HttpResponseMessage response = await HttpClient.GetAsync(new Uri(endpoint.WeakString)).ConfigureAwait(false); + using HttpResponseMessage response = await HttpClient.GetAsync(GetBaseUri(endpoint)).ConfigureAwait(false); return response.IsSuccessStatusCode; } catch (HttpRequestException) @@ -33,6 +68,11 @@ internal static async Task IsAvailableAsync(OllamaEndpoint endpoint) { return false; } + catch (ArgumentException) + { + // GetBaseUri rejects anything NormalizeEndpoint can't turn into an http or https address + return false; + } } internal static async Task DescribeImageAsync(OllamaEndpoint endpoint, OllamaModelName model, string prompt, AbsoluteFilePath imagePath) @@ -68,8 +108,7 @@ private static async Task SendRequestAsync(OllamaEndpoint endpoint, Olla string jsonContent = JsonSerializer.Serialize(request, OllamaJsonContext.Default.OllamaRequest); using StringContent content = new(jsonContent, Encoding.UTF8, "application/json"); - Uri requestUri = new($"{endpoint}/api/generate"); - using HttpResponseMessage response = await HttpClient.PostAsync(requestUri, content).ConfigureAwait(false); + using HttpResponseMessage response = await HttpClient.PostAsync(GetGenerateUri(endpoint), content).ConfigureAwait(false); response.EnsureSuccessStatusCode(); string responseBody = await response.Content.ReadAsStringAsync().ConfigureAwait(false); diff --git a/ImageDescriber/Verbs/BaseVerb.cs b/ImageDescriber/Verbs/BaseVerb.cs index 574a750..00c9f44 100644 --- a/ImageDescriber/Verbs/BaseVerb.cs +++ b/ImageDescriber/Verbs/BaseVerb.cs @@ -24,7 +24,14 @@ internal abstract class BaseVerb : ICommand internal AbsoluteDirectoryPath Path => System.IO.Path.GetFullPath(PathString).As(); - internal OllamaEndpoint Endpoint => string.IsNullOrEmpty(EndpointString) ? Program.Settings.OllamaEndpoint : EndpointString.As(); + internal OllamaEndpoint Endpoint + { + get + { + string value = string.IsNullOrEmpty(EndpointString) ? Program.Settings.OllamaEndpoint.WeakString : EndpointString; + return (OllamaClient.NormalizeEndpoint(value) ?? value).As(); + } + } internal OllamaModelName Model => string.IsNullOrEmpty(ModelString) ? Program.Settings.OllamaModel : ModelString.As(); diff --git a/ImageDescriber/Verbs/Configure.cs b/ImageDescriber/Verbs/Configure.cs index aba6ae5..5cd8876 100644 --- a/ImageDescriber/Verbs/Configure.cs +++ b/ImageDescriber/Verbs/Configure.cs @@ -2,6 +2,8 @@ namespace ktsu.ImageDescriber.Verbs; +using System.IO; + using CommandLine; using ktsu.Semantics.Strings; @@ -21,10 +23,7 @@ internal override void Run(Configure options) Console.Write($"Ollama Endpoint [{Program.Settings.OllamaEndpoint}]: "); string? endpointInput = Console.ReadLine(); - if (!string.IsNullOrWhiteSpace(endpointInput)) - { - Program.Settings.OllamaEndpoint = endpointInput.Trim().As(); - } + Program.Settings.OllamaEndpoint = ChooseEndpoint(endpointInput, Program.Settings.OllamaEndpoint, Console.Out); Console.Write($"Ollama Model [{Program.Settings.OllamaModel}]: "); string? modelInput = Console.ReadLine(); @@ -64,4 +63,25 @@ internal override void Run(Configure options) Console.WriteLine($" Prompt: {Program.Settings.DescriptionPrompt[..Math.Min(60, Program.Settings.DescriptionPrompt.Length)]}..."); Console.WriteLine($" Filename Prompt: {Program.Settings.SuggestedFileNamePrompt[..Math.Min(60, Program.Settings.SuggestedFileNamePrompt.Length)]}..."); } + + /// + /// Returns the normalized endpoint the user typed, or when they + /// typed nothing or something that isn't an http or https address. + /// + internal static OllamaEndpoint ChooseEndpoint(string? input, OllamaEndpoint current, TextWriter output) + { + if (string.IsNullOrWhiteSpace(input)) + { + return current; + } + + string? endpoint = OllamaClient.NormalizeEndpoint(input); + if (endpoint is null) + { + output.WriteLine($" \"{input.Trim()}\" is not an http or https address. Keeping {current}."); + return current; + } + + return endpoint.As(); + } } diff --git a/ImageDescriber/Verbs/Scan.cs b/ImageDescriber/Verbs/Scan.cs index 3cfaafa..5436b38 100644 --- a/ImageDescriber/Verbs/Scan.cs +++ b/ImageDescriber/Verbs/Scan.cs @@ -43,6 +43,12 @@ internal override void Run(Scan options) Console.WriteLine($"Model: {options.Model}"); Console.WriteLine(); + if (OllamaClient.NormalizeEndpoint(options.Endpoint.WeakString) is null) + { + Console.WriteLine($"Error: Invalid Ollama endpoint \"{options.Endpoint}\". Expected an http or https address such as http://localhost:11434."); + return; + } + // Step 1: Check Ollama availability Console.WriteLine("Checking Ollama availability..."); bool isAvailable = OllamaClient.IsAvailableAsync(options.Endpoint).GetAwaiter().GetResult();